diff --git a/.changeset/nice-mice-pump.md b/.changeset/nice-mice-pump.md new file mode 100644 index 0000000000000..3eef62c768279 --- /dev/null +++ b/.changeset/nice-mice-pump.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Adds `separateResponse` param to incoming webhooks to return per-channel statuses; when false or omitted, validates all targets and aborts sending to any channel if one fails. diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 39b1109a948f6..d22c9de0279e8 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -216,7 +216,7 @@ API.v1.addRoute( const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0]; - if (!messageReturn) { + if (!messageReturn?.message) { return API.v1.failure('unknown-error'); } diff --git a/apps/meteor/app/integrations/server/api/api.ts b/apps/meteor/app/integrations/server/api/api.ts index e31666b297e55..59770587170a4 100644 --- a/apps/meteor/app/integrations/server/api/api.ts +++ b/apps/meteor/app/integrations/server/api/api.ts @@ -13,6 +13,7 @@ import { APIClass } from '../../../api/server/ApiClass'; import type { RateLimiterOptions } from '../../../api/server/api'; import { API, defaultRateLimiterOptions } from '../../../api/server/api'; import type { FailureResult, PartialThis, SuccessResult, UnavailableResult } from '../../../api/server/definition'; +import type { WebhookResponseItem } from '../../../lib/server/functions/processWebhookMessage'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { settings } from '../../../settings/server'; import { IsolatedVMScriptEngine } from '../lib/isolated-vm/isolated-vm'; @@ -115,7 +116,12 @@ async function removeIntegration(options: { target_url: string }, user: IUser): async function executeIntegrationRest( this: IntegrationThis, -): Promise | undefined | void> | FailureResult | UnavailableResult> { +): 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 }); @@ -133,6 +139,7 @@ async function executeIntegrationRest( const scriptEngine = getEngine(this.request.integration); let { bodyParams } = this; + const separateResponse = this.bodyParams?.separateResponse === true; let scriptResponse: Record | undefined; if (scriptEngine.integrationHasValidScript(this.request.integration) && this.request.body) { @@ -185,6 +192,11 @@ async function executeIntegrationRest( } bodyParams = result && result.content; + + if (!('separateResponse' in bodyParams)) { + bodyParams.separateResponse = separateResponse; + } + scriptResponse = result.response; if (result.user) { this.user = result.user; @@ -217,16 +229,23 @@ async function executeIntegrationRest( bodyParams.bot = { i: this.request.integration._id }; try { - const message = await processWebhookMessage(bodyParams, this.user, defaultValues); - if (_.isEmpty(message)) { + 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); } - - 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 ({ error, message }: any) { return API.v1.failure(error || message); } diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.ts b/apps/meteor/app/integrations/server/lib/triggerHandler.ts index 32811a656eb37..9f4519e5b01e5 100644 --- a/apps/meteor/app/integrations/server/lib/triggerHandler.ts +++ b/apps/meteor/app/integrations/server/lib/triggerHandler.ts @@ -126,7 +126,7 @@ class RocketChatIntegrationHandler { room?: IRoom; message: { channel: string; bot?: Record; message: Partial }; data: IntegrationData; - }): Promise<{ channel: string; message: Partial } | undefined> { + }): Promise<{ channel: string; message: Partial }[] | undefined> { let user: IUser | null = null; // Try to find the user who we are impersonating if (trigger.impersonateUser) { @@ -180,12 +180,7 @@ class RocketChatIntegrationHandler { channel: tmpRoom.t === 'd' ? `@${tmpRoom._id}` : `#${tmpRoom._id}`, }; - message = (await processWebhookMessage( - message as any, - user as IUser & { username: RequiredField }, - defaultValues, - )) as unknown as { channel: string; message: Partial }; - return message; + return processWebhookMessage(message, user as IUser & { username: RequiredField }, defaultValues); } eventNameArgumentsToObject(...args: unknown[]) { @@ -575,7 +570,7 @@ class RocketChatIntegrationHandler { await updateHistory({ historyId, step: 'after-prepare-send-message', - prepareSentMessage: [prepareMessage], + prepareSentMessage: prepareMessage, }); } @@ -669,7 +664,7 @@ class RocketChatIntegrationHandler { await updateHistory({ historyId, step: 'after-process-send-message', - processSentMessage: [resultMessage], + processSentMessage: resultMessage, finished: true, }); return; @@ -765,7 +760,7 @@ class RocketChatIntegrationHandler { await updateHistory({ historyId, step: 'url-response-sent-message', - resultMessage: [resultMsg], + resultMessage: resultMsg, finished: true, }); } diff --git a/apps/meteor/app/lib/server/functions/processWebhookMessage.ts b/apps/meteor/app/lib/server/functions/processWebhookMessage.ts index 1fe93e0b663a8..374cc091e0634 100644 --- a/apps/meteor/app/lib/server/functions/processWebhookMessage.ts +++ b/apps/meteor/app/lib/server/functions/processWebhookMessage.ts @@ -1,10 +1,10 @@ -import type { IMessage, IUser, RequiredField, MessageAttachment } from '@rocket.chat/core-typings'; +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 { getRoomByNameOrIdWithOptionToJoin } from './getRoomByNameOrIdWithOptionToJoin'; -import { sendMessage } from './sendMessage'; +import { sendMessage, validateMessage } from './sendMessage'; import { ensureArray } from '../../../../lib/utils/arrayUtils'; import { trim } from '../../../../lib/utils/stringUtils'; import { SystemLogger } from '../../../../server/lib/logger/system'; @@ -37,115 +37,168 @@ type DefaultValues = { emoji: string; }; -export const processWebhookMessage = async function ( - messageObj: Payload, +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: IUser & { username: RequiredField }, + defaultValues?: DefaultValues, +): Promise; + +export function processWebhookMessage( + messageObj: Payload & { separateResponse?: false | undefined }, + user: IUser & { username: 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: IUser & { username: RequiredField }, defaultValues: DefaultValues = { channel: '', alias: '', avatar: '', emoji: '' }, ) { - const sentData = []; + 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))]; - for await (const channel of channels) { - const channelType = channel[0]; + if (messageObj.attachments && !Array.isArray(messageObj.attachments)) { + SystemLogger.warn({ + msg: 'Attachments should be Array, ignoring value', + attachments: messageObj.attachments, + }); + messageObj.attachments = undefined; + } - let channelValue = channel.slice(1); - let room; - - switch (channelType) { - case '#': - room = await getRoomByNameOrIdWithOptionToJoin({ - user, - nameOrId: channelValue, - joinChannel: true, - }); - break; - case '@': - room = await getRoomByNameOrIdWithOptionToJoin({ - user, - nameOrId: channelValue, - type: 'd', - }); - break; - default: - channelValue = channelType + channelValue; - - // Try to find the room by id or name if they didn't include the prefix. - room = await getRoomByNameOrIdWithOptionToJoin({ - user, - nameOrId: channelValue, - joinChannel: true, - errorOnEmpty: false, - }); - if (room) { - break; - } - - // We didn't get a room, let's try finding direct messages - room = await getRoomByNameOrIdWithOptionToJoin({ - user, - nameOrId: channelValue, - tryDirectByUserIdOnly: true, - type: 'd', - }); - if (room) { - break; - } - - // No room, so throw an error - throw new Meteor.Error('invalid-channel'); - } + const message = buildMessage(messageObj, defaultValues); - if (messageObj.attachments && !Array.isArray(messageObj.attachments)) { - SystemLogger.warn({ - msg: 'Attachments should be Array, ignoring value', - attachments: messageObj.attachments, - }); - messageObj.attachments = undefined; - } - - 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 (message.msg) { - if (message.msg.length > (settings.get('Message_MaxAllowedSize') ?? 0)) { - throw Error('error-message-size-exceeded'); + for await (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; } + } - 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; - } + for await (const roomData of rooms) { + if ('error' in roomData && roomData.error) { + if (messageObj.separateResponse) { + sentData.push({ channel: roomData.channel, error: roomData.error }); + continue; } } - - await validateRoomMessagePermissionsAsync(room, { uid: user._id, ...user }); - - const messageReturn = await sendMessage(user, removeEmpty(message), room); - sentData.push({ channel, message: messageReturn }); + const messageReturn = await sendMessage(user, removeEmpty(message), roomData.room); + sentData.push({ channel: roomData.channel, message: messageReturn }); } return sentData; -}; +} diff --git a/apps/meteor/server/modules/core-apps/mention.module.ts b/apps/meteor/server/modules/core-apps/mention.module.ts index 76d3a22ea53b7..8bd5bec4ce090 100644 --- a/apps/meteor/server/modules/core-apps/mention.module.ts +++ b/apps/meteor/server/modules/core-apps/mention.module.ts @@ -95,6 +95,7 @@ export class MentionModule implements IUiKitCoreApp { { roomId: mentions.map(({ _id }) => _id), text, + separateResponse: true, // so that messages are sent to other DMs even if one or more fails }, payload.user, ); diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 1022edef686ed..51d61cab20fc5 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -126,3 +126,15 @@ export const setUserOnline = (overrideCredentials = credentials) => msg: 'method', }), }); + +export const removeRoleFromUser = (username: string, roleId: string, overrideCredentials = credentials) => + getUserByUsername(username).then((user) => + request + .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/end-to-end/api/incoming-integrations.ts b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts index 4e689d5e4efb0..bdc1691f79869 100644 --- a/apps/meteor/tests/end-to-end/api/incoming-integrations.ts +++ b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts @@ -10,9 +10,9 @@ import { createIntegration, removeIntegration } from '../../data/integration.hel import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { createTeam, deleteTeam } from '../../data/teams.helper'; -import { password } from '../../data/user'; +import { adminUsername, password } from '../../data/user'; import type { TestUser } from '../../data/users.helper'; -import { createUser, deleteUser, login } from '../../data/users.helper'; +import { createUser, deleteUser, login, removeRoleFromUser } from '../../data/users.helper'; describe('[Incoming Integrations]', () => { let integration: IIntegration; @@ -889,7 +889,7 @@ describe('[Incoming Integrations]', () => { }); }); - describe('Additional Tests for Message Delivery Permissions And Authentication', () => { + describe('Additional Tests for Message Delivery, Permissions, and Authentication', () => { let nonMemberUser: IUser; let privateTeam: ITeam; let publicChannelInPrivateTeam: IRoom; @@ -898,6 +898,8 @@ describe('[Incoming Integrations]', () => { let integration2: IIntegration; let integration3: IIntegration; let integration4: IIntegration; + let integrationMixed1: IIntegration; + let integrationMixed2: IIntegration; before(async () => { nonMemberUser = await createUser({ username: `g_${Random.id()}` }); @@ -990,6 +992,48 @@ describe('[Incoming Integrations]', () => { 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 () => { @@ -999,7 +1043,7 @@ describe('[Incoming Integrations]', () => { await deleteTeam(credentials, privateTeam.name); await deleteUser(nonMemberUser); await Promise.all([ - ...[integration2, integration3, integration4].map((integration) => + ...[integration2, integration3, integration4, integrationMixed1, integrationMixed2].map((integration) => request.post(api('integrations.remove')).set(credentials).send({ integrationId: integration._id, type: integration.type, @@ -1007,6 +1051,7 @@ describe('[Incoming Integrations]', () => { ), updatePermission('manage-incoming-integrations', ['admin']), ]); + await removeRoleFromUser(adminUsername, 'bot'); }); it('should not send a message in public room if token is invalid', async () => { @@ -1264,5 +1309,194 @@ describe('[Incoming Integrations]', () => { }); }); }); + + 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); + }); + }); + }); }); });