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

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

View File

@@ -0,0 +1,40 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import mock from 'proxyquire';
const userPermissions: Record<string, string[]> = {
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<boolean> =>
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;
});
});

View File

@@ -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');
});
});

View File

@@ -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;
});
});

View File

@@ -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);
});
});
});

View File

@@ -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');
});
});

View File

@@ -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<string, unknown> = {}) =>
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;
});
});

View File

@@ -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 });
});
});

View File

@@ -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,
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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({});
});
});
});

View File

@@ -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;
});
});

View File

@@ -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);
});
});

View File

@@ -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;
});
});

View File

@@ -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' });
});
});

View File

@@ -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;
});
});

View File

@@ -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;
});
});

View File

@@ -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;
});
});

View File

@@ -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(
`<p><strong>${messagesData[3].username}</strong> (${new Date(messagesData[3].ts).toUTCString()}):<br/>\n${messagesData[3].msg}\n</p>`,
);
});
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(
`<p><strong>${messagesData[0].username}</strong> (${new Date(messagesData[0].ts).toUTCString()}):<br/>\n<i>${
messagesData[0].msg
}</i>\n</p>`,
);
});
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(
`<p><strong>${messagesData[4].username}</strong> (${new Date(messagesData[4].ts).toUTCString()}):<br/>\n${messagesData[4].msg}\n</p>`,
);
});
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(
`<p><strong>${messagesData[1].username}</strong> (${new Date(messagesData[1].ts).toUTCString()}):<br/>\n${
messagesData[1].msg
}\n<br/><a href="./assets/${exportMessagesMock[1].file?._id}-${exportMessagesMock[1].file?.name}">${
messagesData[1].attachments?.[0].title
}</a>\n</p>`,
);
});
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(
`<p><strong>${messagesData[2].username}</strong> (${new Date(messagesData[2].ts).toUTCString()}):<br/>\n${
exportMessagesMock[1].msg
}\n<br/><a href="./assets/${exportMessagesMock[2].file?._id}-${
exportMessagesMock[2].file?.name
}">${translationPlaceholder}</a>\n</p>`,
);
});
});
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: '<img src=x onerror="alert(1)">',
username: 'attacker',
ts,
});
expect(result).to.contain('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
expect(result).to.not.contain('<img src=x');
});
it('should escape HTML in the username when exporting as html', async () => {
const result = await exportMessageObject('html', {
msg: 'hello',
username: '<script>alert(1)</script>',
ts,
});
expect(result).to.contain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(result).to.not.contain('<script>');
});
it('should escape HTML inside italicized (system) messages when exporting as html', async () => {
const result = await exportMessageObject('html', {
msg: '<img src=x onerror=alert(1)>',
username: 'attacker',
ts,
type: 'wm',
});
expect(result).to.contain('<i>&lt;img src=x onerror=alert(1)&gt;</i>');
expect(result).to.not.contain('<img src=x');
});
it('should escape HTML in the attachment description and prevent href attribute breakout', async () => {
const result = await exportMessageObject(
'html',
{
msg: 'file',
username: 'attacker',
ts,
attachments: [
{
type: 'file',
title: '<img src=x onerror=alert(1)>',
title_link: '/file-upload/evil-id/x',
},
] as any,
},
[{ _id: 'evil-id', name: '"><img src=x onerror=alert(1)>.png' } as any],
);
expect(result).to.contain('&lt;img src=x');
expect(result).to.not.contain('<img src=x');
// the crafted filename must not break out of the href="" attribute
expect(result).to.not.contain('"><img');
});
it('should not escape plain JSON exports (escaping is html-only)', async () => {
const messageObject = { msg: '<b>x</b>', username: 'attacker', ts };
const result = await exportMessageObject('json', messageObject);
expect(result).to.equal(JSON.stringify(messageObject));
});
});
describe('Export - exportRoomMessages', () => {
const totalMessages = 10;
const userData = {
_id: faker.database.mongodbObjectId(),
name: faker.person.fullName(),
username: faker.internet.userName(),
};
before(() => {
stubs.findPaginatedMessagesCursor.resolves(exportMessagesMock);
stubs.findPaginatedMessagesTotal.resolves(totalMessages);
stubs.findPaginatedMessages.returns({
cursor: { toArray: stubs.findPaginatedMessagesCursor },
totalCount: stubs.findPaginatedMessagesTotal(),
});
stubs.translateKey.returns('translated-placeholder-uj');
});
it('should correctly export multiple messages to result when exporting room as json', async () => {
const result = await exportRoomMessages('test-rid', 'json', 0, 100, userData);
expect(stubs.translateKey.calledWith('User_joined_the_channel')).to.be.true;
expect(result).to.be.an('object');
expect(result).to.have.property('total', totalMessages);
expect(result).to.have.property('exported', exportMessagesMock.length);
expect(result).to.have.property('messages').that.is.an('array').of.length(exportMessagesMock.length);
const messagesWithFiles = exportMessagesMock.filter((message) => message.file);
expect(result).to.have.property('uploads').that.is.an('array').of.length(messagesWithFiles.length);
});
it('should correctly export multiple messages to result when exporting room as html', async () => {
const result = await exportRoomMessages('test-rid', 'html', 0, 100, userData);
expect(stubs.translateKey.calledWith('User_joined_the_channel')).to.be.true;
expect(result).to.be.an('object');
expect(result).to.have.property('total', totalMessages);
expect(result).to.have.property('exported', exportMessagesMock.length);
expect(result).to.have.property('messages').that.is.an('array').of.length(exportMessagesMock.length);
const messagesWithFiles = exportMessagesMock.filter((message) => message.file);
expect(result).to.have.property('uploads').that.is.an('array').of.length(messagesWithFiles.length);
});
it('should export multiple files and filter out thumbnails', async () => {
const message = {
_id: faker.database.mongodbObjectId(),
rid: 'test-rid',
ts: new Date(),
u: { _id: faker.database.mongodbObjectId(), username: 'testuser' },
msg: 'Message with files',
files: [
{ _id: 'file-1', name: 'photo.jpg', type: 'image/jpeg', size: 500000 },
{ _id: 'file-2', name: 'doc.pdf', type: 'application/pdf', size: 10000 },
{ _id: 'thumb-1', name: 'photo_thumb.jpg', type: 'image/jpeg', size: 5000, typeGroup: 'thumb' },
],
attachments: [{ type: 'file', title: 'photo.jpg', title_link: '/file-upload/file-1/photo.jpg' }],
};
stubs.findPaginatedMessagesCursor.resolves([message]);
stubs.findPaginatedMessagesTotal.resolves(1);
stubs.findPaginatedMessages.returns({
cursor: { toArray: stubs.findPaginatedMessagesCursor },
totalCount: stubs.findPaginatedMessagesTotal(),
});
const result = await exportRoomMessages('test-rid', 'html', 0, 100, userData);
expect(result.uploads).to.have.length(2);
expect(result.uploads.some((f: { typeGroup?: string }) => f.typeGroup === 'thumb')).to.be.false;
});
it('should fallback to msg.file when msg.files is not available', async () => {
const message = {
_id: faker.database.mongodbObjectId(),
rid: 'test-rid',
ts: new Date(),
u: { _id: faker.database.mongodbObjectId(), username: 'testuser' },
msg: 'Old format message',
file: { _id: 'single-file', name: 'doc.pdf', type: 'application/pdf', size: 10000 },
attachments: [{ type: 'file', title: 'doc.pdf', title_link: '/file-upload/single-file/doc.pdf' }],
};
stubs.findPaginatedMessagesCursor.resolves([message]);
stubs.findPaginatedMessagesTotal.resolves(1);
stubs.findPaginatedMessages.returns({
cursor: { toArray: stubs.findPaginatedMessagesCursor },
totalCount: stubs.findPaginatedMessagesTotal(),
});
const result = await exportRoomMessages('test-rid', 'html', 0, 100, userData);
expect(result.uploads).to.have.length(1);
expect(result.uploads[0]._id).to.equal('single-file');
});
});

View File

@@ -0,0 +1,153 @@
import { expect } from 'chai';
import { before, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
// Create stubs for dependencies
const stubs = {
findOneUserById: sinon.stub(),
randomId: sinon.stub(),
stat: sinon.stub(),
getStore: sinon.stub(),
insertFileStub: sinon.stub(),
createReadStream: sinon.stub(),
};
const { uploadZipFile } = proxyquire.noCallThru().load('../../../../../server/lib/dataExport/uploadZipFile.ts', {
'@rocket.chat/models': {
Users: {
findOneById: stubs.findOneUserById,
},
},
'@rocket.chat/random': {
Random: {
id: stubs.randomId,
},
},
'node:fs/promises': {
stat: stubs.stat,
},
'node:fs': {
createReadStream: stubs.createReadStream,
},
'../media/file-upload': {
FileUpload: {
getStore: stubs.getStore,
},
},
});
describe('Export - uploadZipFile', () => {
const randomId = 'random-id';
const fileStat = 100;
const userName = 'John Doe';
const userUsername = 'john.doe';
const userId = 'user-id';
const filePath = 'random-path';
before(() => {
stubs.findOneUserById.returns({ name: userName });
stubs.stat.returns({ size: fileStat });
stubs.randomId.returns(randomId);
stubs.getStore.returns({ insert: stubs.insertFileStub });
stubs.insertFileStub.callsFake((details) => ({ _id: details._id, name: details.name }));
});
it('should correctly build file name for json exports', async () => {
const result = await uploadZipFile(filePath, userId, 'json');
expect(stubs.findOneUserById.calledWith(userId)).to.be.true;
expect(stubs.stat.calledWith(filePath)).to.be.true;
expect(stubs.createReadStream.calledWith(filePath)).to.be.true;
expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true;
expect(
stubs.insertFileStub.calledWith(
sinon.match({
_id: randomId,
userId,
type: 'application/zip',
size: fileStat,
}),
),
).to.be.true;
expect(result).to.have.property('_id', randomId);
expect(result).to.have.property('name').that.is.a.string;
const fileName: string = result.name;
expect(fileName.endsWith(encodeURIComponent(`${userName}-data-${randomId}.zip`))).to.be.true;
});
it('should correctly build file name for html exports', async () => {
const result = await uploadZipFile(filePath, userId, 'html');
expect(stubs.findOneUserById.calledWith(userId)).to.be.true;
expect(stubs.stat.calledWith(filePath)).to.be.true;
expect(stubs.createReadStream.calledWith(filePath)).to.be.true;
expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true;
expect(
stubs.insertFileStub.calledWith(
sinon.match({
_id: randomId,
userId,
type: 'application/zip',
size: fileStat,
}),
),
).to.be.true;
expect(result).to.have.property('_id', randomId);
expect(result).to.have.property('name').that.is.a.string;
const fileName: string = result.name;
expect(fileName.endsWith(encodeURIComponent(`${userName}-${randomId}.zip`))).to.be.true;
});
it("should use username as a fallback in the zip file name when user's name is not defined", async () => {
stubs.findOneUserById.returns({ username: userUsername });
const result = await uploadZipFile(filePath, userId, 'html');
expect(stubs.findOneUserById.calledWith(userId)).to.be.true;
expect(stubs.stat.calledWith(filePath)).to.be.true;
expect(stubs.createReadStream.calledWith(filePath)).to.be.true;
expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true;
expect(
stubs.insertFileStub.calledWith(
sinon.match({
_id: randomId,
userId,
type: 'application/zip',
size: fileStat,
}),
),
).to.be.true;
expect(result).to.have.property('_id', randomId);
expect(result).to.have.property('name').that.is.a.string;
const fileName: string = result.name;
expect(fileName.endsWith(`${userUsername}-${randomId}.zip`)).to.be.true;
});
it("should use userId as a fallback in the zip file name when user's name and username are not defined", async () => {
stubs.findOneUserById.returns(undefined);
const result = await uploadZipFile(filePath, userId, 'html');
expect(stubs.findOneUserById.calledWith(userId)).to.be.true;
expect(stubs.stat.calledWith(filePath)).to.be.true;
expect(stubs.createReadStream.calledWith(filePath)).to.be.true;
expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true;
expect(
stubs.insertFileStub.calledWith(
sinon.match({
_id: randomId,
userId,
type: 'application/zip',
size: fileStat,
}),
),
).to.be.true;
expect(result).to.have.property('_id', randomId);
expect(result).to.have.property('name').that.is.a.string;
const fileName: string = result.name;
expect(fileName.endsWith(`${userId}-${randomId}.zip`)).to.be.true;
});
});

View File

@@ -0,0 +1,207 @@
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import { generateMultipleSubs } from '../../../../../mocks/data/subscriptions';
function addSecondsToDate(seconds: number, date = new Date()) {
return new Date(date.getTime() + seconds * 1000);
}
const models = {
Users: {
findOneById: sinon.stub(),
},
Rooms: {
findOneById: sinon.stub(),
resetRoomKeyAndSetE2EEQueueByRoomId: sinon.stub(),
},
Subscriptions: {
find: sinon.stub(),
col: {
bulkWrite: sinon.stub(),
},
setE2EKeyByUserIdAndRoomId: sinon.stub(),
},
};
const { resetRoomKey, pushToLimit, replicateMongoSlice } = proxyquire
.noCallThru()
.load('../../../../../../server/lib/e2e/functions/resetRoomKey', {
'@rocket.chat/models': models,
'../../notifyListener': {
notifyOnRoomChanged: sinon.stub(),
notifyOnSubscriptionChanged: sinon.stub(),
},
});
describe('pushToLimit', () => {
it('should push up to a limit', () => {
const arr: any[] = [];
pushToLimit(arr, { foo: 'bar' }, 2);
expect(arr).to.have.lengthOf(1);
expect(arr[0]).to.deep.equal({ foo: 'bar' });
pushToLimit(arr, { foo: 'bar' }, 2);
expect(arr).to.have.lengthOf(2);
expect(arr[0]).to.deep.equal({ foo: 'bar' });
expect(arr[1]).to.deep.equal({ foo: 'bar' });
pushToLimit(arr, { foo: 'bzz' }, 2);
expect(arr).to.have.lengthOf(2);
expect(arr[0]).to.deep.equal({ foo: 'bar' });
expect(arr[1]).to.deep.equal({ foo: 'bar' });
expect(arr.filter((e: any) => e.foo === 'bzz')).to.have.lengthOf(0);
});
});
describe('replicateMongoSlice', () => {
it('should do nothing if sub has no E2EKey', () => {
expect(replicateMongoSlice('1', { oldRoomKeys: [] })).to.be.undefined;
});
it('should return an array with the new E2EKey as an old key when there is no oldkeys', () => {
expect(replicateMongoSlice('1', { E2EKey: '1' })[0].E2EKey).to.equal('1');
});
it('should unshift a new key if sub has E2EKey and oldRoomKeys', () => {
expect(replicateMongoSlice('1', { oldRoomKeys: [{ E2EKey: '1', ts: new Date() }], E2EKey: '2' })[0].E2EKey).to.equal('2');
});
it('should unshift a new key, and eliminate the 10th key if array has 10 items', () => {
const result = replicateMongoSlice('1', {
oldRoomKeys: [
{ E2EKey: '1', ts: addSecondsToDate(0) },
{ E2EKey: '2', ts: addSecondsToDate(-10) },
{ E2EKey: '3', ts: addSecondsToDate(-20) },
{ E2EKey: '4', ts: addSecondsToDate(-30) },
{ E2EKey: '5', ts: addSecondsToDate(-40) },
{ E2EKey: '6', ts: addSecondsToDate(-50) },
{ E2EKey: '7', ts: addSecondsToDate(-60) },
{ E2EKey: '8', ts: addSecondsToDate(-70) },
{ E2EKey: '9', ts: addSecondsToDate(-80) },
{ E2EKey: '10', ts: addSecondsToDate(-90) },
],
E2EKey: '11',
});
expect(result[0].E2EKey).to.equal('11');
expect(result[9].E2EKey).to.equal('9');
expect(result).to.have.lengthOf(10);
});
});
describe('resetRoomKey', () => {
beforeEach(() => {
models.Users.findOneById.reset();
models.Rooms.findOneById.reset();
models.Rooms.resetRoomKeyAndSetE2EEQueueByRoomId.reset();
models.Subscriptions.setE2EKeyByUserIdAndRoomId.reset();
models.Subscriptions.col.bulkWrite.reset();
});
it('should fail if userId does not exist', async () => {
models.Users.findOneById.resolves(null);
await expect(resetRoomKey('1', '2', '3', '4')).to.be.rejectedWith('error-user-not-found');
});
it('should fail if the user has no e2e keys', async () => {
models.Users.findOneById.resolves({});
await expect(resetRoomKey('1', '2', '3', '4')).to.be.rejectedWith('error-user-has-no-keys');
models.Users.findOneById.resolves({ e2e: { private_key: 'a' } });
await expect(resetRoomKey('1', '2', '3', '4')).to.be.rejectedWith('error-user-has-no-keys');
models.Users.findOneById.resolves({ e2e: { public_key: 'b' } });
await expect(resetRoomKey('1', '2', '3', '4')).to.be.rejectedWith('error-user-has-no-keys');
});
it('should fail if roomId does not exist', async () => {
models.Users.findOneById.resolves({ e2e: { private_key: 'a', public_key: 'b' } });
models.Rooms.findOneById.resolves(null);
await expect(resetRoomKey('1', '2', '3', '4')).to.be.rejectedWith('error-room-not-found');
});
it('should fail if room does not have a keyId', async () => {
models.Users.findOneById.resolves({ e2e: { private_key: 'a', public_key: 'b' } });
models.Rooms.findOneById.resolves({ e2eKeyId: null });
await expect(resetRoomKey('1', '2', '3', '4')).to.be.rejectedWith('error-room-not-encrypted');
});
it('should try to process subs', async () => {
const subs = generateMultipleSubs(10);
function* generateSubs() {
for (const sub of subs) {
yield { ...sub };
}
}
models.Users.findOneById.resolves({ e2e: { private_key: 'a', public_key: 'b' } });
models.Rooms.findOneById.resolves({ e2eKeyId: '5' });
models.Subscriptions.find.returns(generateSubs());
models.Rooms.resetRoomKeyAndSetE2EEQueueByRoomId.resolves({ value: { e2eKeyId: '5' } });
models.Subscriptions.setE2EKeyByUserIdAndRoomId.resolves({ value: { e2eKeyId: '5' } });
await resetRoomKey('1', '2', '3', '4');
expect(models.Subscriptions.col.bulkWrite.callCount).to.equal(1);
const updateOps = models.Subscriptions.col.bulkWrite.getCall(0).args[0];
expect(updateOps).to.have.lengthOf(10);
expect(updateOps.every((op: any) => op.updateOne)).to.be.true;
updateOps.forEach((op: any) => {
const sub = subs.find((s: any) => s._id === op.updateOne.filter._id);
expect(op.updateOne.update.$unset).to.be.deep.equal({ E2EKey: 1, E2ESuggestedKey: 1, suggestedOldRoomKeys: 1 });
if (sub?.E2EKey && sub?.oldRoomKeys?.length < 10) {
expect(op.updateOne.update.$set.oldRoomKeys).to.have.length(sub.oldRoomKeys.length + 1);
expect(
op.updateOne.update.$set.oldRoomKeys.every((key: any) =>
[...sub.oldRoomKeys.map((k: any) => k.E2EKey), sub.E2EKey].includes(key.E2EKey),
),
).to.be.true;
}
if (!sub?.E2EKey && !sub?.oldRoomKeys) {
expect(op.updateOne.update).to.not.to.have.property('$set');
}
if (sub?.oldRoomKeys?.length === 10 && sub?.E2EKey) {
expect(op.updateOne.update.$set.oldRoomKeys).to.have.length(10);
expect(op.updateOne.update.$set.oldRoomKeys[0].E2EKey).to.equal(sub.E2EKey);
}
});
expect(models.Rooms.resetRoomKeyAndSetE2EEQueueByRoomId.getCall(0).args[0]).to.equal('1');
expect(models.Subscriptions.setE2EKeyByUserIdAndRoomId.getCall(0).args).to.deep.equal(['2', '1', '3']);
});
it('should process more than 100 subs', async () => {
const subs = generateMultipleSubs(150);
function* generateSubs() {
for (const sub of subs) {
yield { ...sub };
}
}
models.Users.findOneById.resolves({ e2e: { private_key: 'a', public_key: 'b' } });
models.Rooms.findOneById.resolves({ e2eKeyId: '5' });
models.Subscriptions.find.returns(generateSubs());
models.Rooms.resetRoomKeyAndSetE2EEQueueByRoomId.resolves({ value: { e2eKeyId: '5' } });
models.Subscriptions.setE2EKeyByUserIdAndRoomId.resolves({ value: { e2eKeyId: '5' } });
await resetRoomKey('1', '2', '3', '4');
expect(models.Subscriptions.col.bulkWrite.callCount).to.equal(2);
expect(models.Subscriptions.col.bulkWrite.getCall(0).args[0]).to.have.lengthOf(100);
expect(models.Subscriptions.col.bulkWrite.getCall(1).args[0]).to.have.lengthOf(50);
});
});

View File

@@ -0,0 +1,27 @@
import { expect } from 'chai';
import { fileName, joinPath } from '../../../../server/lib/fileUtils';
describe('File utils', () => {
it('should return a valid file name', () => {
expect(fileName('something')).to.equal('something');
expect(fileName('some@thing')).to.equal('some@thing');
expect(fileName('/something')).to.equal('something');
expect(fileName('/some/thing')).to.equal('some-thing');
expect(fileName('/some/thing/')).to.equal('some-thing');
expect(fileName('///some///thing///')).to.equal('some-thing');
expect(fileName('some/thing')).to.equal('some-thing');
expect(fileName('some:"thing"')).to.equal('some-thing');
expect(fileName('some:"thing".txt')).to.equal('some-thing-.txt');
expect(fileName('some"thing"')).to.equal('some-thing');
expect(fileName('some\u0000thing')).to.equal('some-thing');
});
it('should return a valid joined path', () => {
expect(joinPath('/app', 'some@thing')).to.equal('/app/some@thing');
expect(joinPath('../app', 'something')).to.equal('../app/something');
expect(joinPath('../app/', 'something')).to.equal('../app/something');
expect(joinPath('../app/', '/something')).to.equal('../app/something');
expect(joinPath('/app', '/something')).to.equal('/app/something');
expect(joinPath('/app', '/../some/thing')).to.equal('/app/..-some-thing');
});
});

View File

@@ -0,0 +1,430 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import AdmZip from 'adm-zip';
import { expect } from 'chai';
import { parse } from 'csv-parse/sync';
import { after, before, beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import { ProgressStep } from '../../../../../app/importer/lib/ImporterProgressStep';
const converter = {
clearImportData: sinon.stub(),
addChannel: sinon.stub(),
addUser: sinon.stub(),
addMessage: sinon.stub(),
addContact: sinon.stub(),
};
const logger = {
debug: sinon.stub(),
warn: sinon.stub(),
error: sinon.stub(),
};
const progressUpdated = sinon.stub();
const updateProgress = sinon.stub();
const updateRecord = sinon.stub();
const incrementValueById = sinon.stub();
const notifyOnSettingChanged = sinon.stub();
const findOneByUsername = sinon.stub();
const addImportIds = sinon.stub();
class MockImporter {
protected AdmZip = AdmZip;
protected logger = logger;
protected converter = converter;
public importRecord: any;
public progress: any;
constructor(_info: any, importRecord: any) {
this.importRecord = importRecord;
this.progress = { step: ProgressStep.PREPARING_STARTED, count: { total: 0 } };
}
async updateProgress(step: string) {
updateProgress(step);
this.progress.step = step;
return this.progress;
}
async updateRecord(fields: any) {
updateRecord(fields);
return this.importRecord;
}
async addCountToTotal(count: number) {
this.progress.count.total += count;
return this.progress;
}
getProgress() {
return this.progress;
}
}
const { CsvImporter } = proxyquire.noCallThru().load('../../../../../server/lib/import/csv/CsvImporter', {
'@rocket.chat/models': {
Settings: { incrementValueById },
Users: { findOneByUsername, addImportIds },
},
'csv-parse/lib/sync': { parse },
'..': {
Importer: MockImporter,
ProgressStep,
ImporterWebsocket: { progressUpdated },
},
'../../notifyListener': { notifyOnSettingChanged },
});
const allStubs = [
...Object.values(converter),
...Object.values(logger),
progressUpdated,
updateProgress,
updateRecord,
incrementValueById,
notifyOnSettingChanged,
findOneByUsername,
addImportIds,
];
type ZipEntry = { name: string; content?: string };
describe('CsvImporter', () => {
let tmpDir: string;
let zipCount = 0;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-csv-importer-'));
});
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
beforeEach(() => {
allStubs.forEach((stub) => stub.reset());
converter.clearImportData.resolves();
converter.addChannel.resolves();
converter.addUser.resolves();
converter.addMessage.resolves();
converter.addContact.resolves();
incrementValueById.resolves(null);
findOneByUsername.resolves(null);
addImportIds.resolves();
});
const writeZip = (entries: ZipEntry[]): string => {
const zip = new AdmZip();
for (const entry of entries) {
zip.addFile(entry.name, Buffer.from(entry.content ?? ''));
}
zipCount += 1;
const filePath = path.join(tmpDir, `archive-${zipCount}.zip`);
zip.writeZip(filePath);
return filePath;
};
const runImport = async (entries: ZipEntry[]) => {
const importer = new CsvImporter({ key: 'csv', name: 'CSV' }, { _id: 'import-id' });
const progress = await importer.prepareUsingLocalFile(writeZip(entries));
return { importer, progress };
};
const rates = () => progressUpdated.getCalls().map((call) => call.args[0].rate);
const CHANNELS_CSV = ' general , admin , Public , alice ; bob \nsecret,owner,Private,carol;;dave;';
const USERS_CSV = ' alice , alice@example.com , Alice Smith \nbob,bob@example.com,Bob Jones';
const CONTACTS_CSV = 'name,emails,phones\nJane Doe,jane@example.com,+5511999';
const CHANNEL_MESSAGES_CSV = 'alice,1700000000000,hello there\nbob,1700000001000,hi';
const DM_MESSAGES_CSV = 'alice,bob,1700000002000,dm from alice\nbob,alice,1700000003000,dm from bob';
const fullArchive = (): ZipEntry[] => [
{ name: 'channels.csv', content: CHANNELS_CSV },
{ name: 'users.csv', content: USERS_CSV },
{ name: 'contacts.csv', content: CONTACTS_CSV },
{ name: 'general/messages.csv', content: CHANNEL_MESSAGES_CSV },
{ name: 'directmessages/messages.csv', content: DM_MESSAGES_CSV },
];
describe('archive setup and progress', () => {
it('should clear previously imported data before reading the archive', async () => {
await runImport(fullArchive());
expect(converter.clearImportData.calledOnce).to.be.true;
sinon.assert.callOrder(converter.clearImportData, converter.addUser);
});
it('should not emit a progress update when the calculated rate did not increase', async () => {
const entries = Array.from({ length: 1001 }, (_, index) => ({ name: `__MACOSX/ignored-${index}`, content: '' }));
await runImport(entries);
expect(rates().filter((rate) => rate === 0)).to.have.lengthOf(1);
expect(progressUpdated.callCount).to.equal(1002);
});
it('should log a progress update failure and keep importing', async () => {
progressUpdated.onSecondCall().throws(new Error('streamer is down'));
const { progress } = await runImport(fullArchive());
expect(logger.error.calledWithMatch({ msg: 'Error while increasing CSV import progress' })).to.be.true;
expect(converter.addUser.callCount).to.equal(2);
expect(progress.step).to.not.equal(ProgressStep.ERROR);
expect(rates()[rates().length - 1]).to.equal(100);
});
});
describe('archive filtering and room identification', () => {
it('should ignore __MACOSX and directory entries, advancing the progress rate for every entry', async () => {
await runImport([
{ name: '__MACOSX/general/messages.csv', content: CHANNEL_MESSAGES_CSV },
{ name: 'general/', content: '' },
{ name: 'general/messages.csv', content: CHANNEL_MESSAGES_CSV },
{ name: 'readme.txt', content: 'not a csv the importer knows about' },
]);
expect(converter.addMessage.callCount).to.equal(2);
expect(updateRecord.calledWithMatch({ messagesstatus: 'general/messages' })).to.be.true;
expect(updateRecord.calledWithMatch({ messagesstatus: 'general/' })).to.be.false;
expect(rates()).to.deep.equal([0, 25, 50, 75, 100, 100]);
});
it('should reuse the same generated room id for every reference to a room name', async () => {
await runImport([
{ name: 'channels.csv', content: 'general,admin,public,alice' },
{ name: 'general/first.csv', content: 'alice,1700000000000,one' },
{ name: 'general/second.csv', content: 'bob,1700000001000,two' },
]);
const channelId = converter.addChannel.firstCall.args[0].importIds[0];
expect(channelId).to.be.a('string').and.not.empty;
expect(converter.addMessage.getCalls().map((call) => call.args[0].rid)).to.deep.equal([channelId, channelId]);
});
});
describe('channel parsing', () => {
it('should parse channels.csv case-insensitively, trimming every field and dropping empty members', async () => {
await runImport([{ name: 'Channels.CSV', content: CHANNELS_CSV }]);
expect(updateProgress.calledWith(ProgressStep.PREPARING_CHANNELS)).to.be.true;
expect(converter.addChannel.firstCall.args[0]).to.deep.include({
name: 'general',
u: { _id: 'admin' },
users: ['alice', 'bob'],
});
expect(converter.addChannel.secondCall.args[0].users).to.deep.equal(['carol', 'dave']);
expect(updateRecord.calledWithExactly({ 'count.channels': 2 })).to.be.true;
});
it('should map private channels to p and every other channel to c', async () => {
await runImport([{ name: 'channels.csv', content: CHANNELS_CSV }]);
expect(converter.addChannel.firstCall.args[0].t).to.equal('c');
expect(converter.addChannel.secondCall.args[0].t).to.equal('p');
});
});
describe('user and contact parsing', () => {
it('should parse users.csv case-insensitively into importer user records', async () => {
await runImport([{ name: 'Users.CSV', content: USERS_CSV }]);
expect(updateProgress.calledWith(ProgressStep.PREPARING_USERS)).to.be.true;
expect(converter.addUser.callCount).to.equal(2);
expect(converter.addUser.firstCall.args[0]).to.deep.equal({
type: 'user',
importIds: ['alice'],
emails: ['alice@example.com'],
username: 'alice',
name: 'Alice Smith',
});
expect(updateRecord.calledWithExactly({ 'count.users': 2 })).to.be.true;
});
it('should delegate contacts.csv rows to the contact importer and count the accepted ones', async () => {
await runImport([{ name: 'Contacts.CSV', content: `${CONTACTS_CSV}\n,,` }]);
expect(updateProgress.calledWith(ProgressStep.PREPARING_CONTACTS)).to.be.true;
expect(converter.addContact.callCount).to.equal(1);
expect(converter.addContact.firstCall.args[0]).to.deep.include({
name: 'Jane Doe',
emails: ['jane@example.com'],
phones: ['+5511999'],
});
expect(updateRecord.calledWithExactly({ 'count.contacts': 1 })).to.be.true;
});
});
describe('message parsing', () => {
it('should move to the message preparation step only once for the whole archive', async () => {
await runImport([
{ name: 'general/first.csv', content: CHANNEL_MESSAGES_CSV },
{ name: 'general/second.csv', content: CHANNEL_MESSAGES_CSV },
]);
expect(updateProgress.withArgs(ProgressStep.PREPARING_MESSAGES).callCount).to.equal(1);
});
it('should skip a message file with invalid csv syntax and warn about it', async () => {
await runImport([
{ name: 'general/broken.csv', content: 'alice,1700000000000,"unclosed' },
{ name: 'general/valid.csv', content: CHANNEL_MESSAGES_CSV },
]);
expect(logger.warn.calledWithMatch({ msg: 'The file contains invalid syntax', entryName: 'general/broken.csv' })).to.be.true;
expect(converter.addMessage.callCount).to.equal(2);
expect(updateRecord.calledWithMatch({ messagesstatus: 'general/broken' })).to.be.false;
});
it('should convert a channel message with its room, author, timestamp and text', async () => {
await runImport([{ name: 'general/messages.csv', content: CHANNEL_MESSAGES_CSV }]);
const message = converter.addMessage.firstCall.args[0];
expect(Object.keys(message).sort()).to.deep.equal(['msg', 'rid', 'ts', 'u']);
expect(message.rid).to.be.a('string').and.not.empty;
expect(message).to.deep.include({
u: { _id: 'alice' },
ts: new Date(1700000000000),
msg: 'hello there',
});
expect(converter.addMessage.secondCall.args[0]).to.deep.include({
rid: message.rid,
u: { _id: 'bob' },
ts: new Date(1700000001000),
msg: 'hi',
});
});
it('should convert direct messages into a single room per participant pair, whatever the username order', async () => {
await runImport([{ name: 'directmessages/messages.csv', content: DM_MESSAGES_CSV }]);
expect(converter.addChannel.calledOnce).to.be.true;
expect(converter.addChannel.firstCall.args[0]).to.deep.equal({
importIds: ['alice/bob'],
users: ['alice', 'bob'],
t: 'd',
});
expect(converter.addMessage.firstCall.args[0]).to.deep.equal({
rid: 'alice/bob',
u: { _id: 'alice' },
ts: new Date(1700000002000),
msg: 'dm from alice',
});
expect(converter.addMessage.getCalls().map((call) => call.args[0].rid)).to.deep.equal(['alice/bob', 'alice/bob']);
});
it('should skip a direct message row that has no other participant', async () => {
await runImport([{ name: 'directmessages/messages.csv', content: 'alice,,1700000002000,orphan' }]);
expect(converter.addChannel.called).to.be.false;
expect(converter.addMessage.called).to.be.false;
});
it('should accumulate the message count across files, clearing the file status after each one', async () => {
await runImport([
{ name: 'general/first.csv', content: CHANNEL_MESSAGES_CSV },
{ name: 'general/second.csv', content: 'alice,1700000004000,third' },
]);
expect(updateRecord.calledWithExactly({ 'count.messages': 2, 'messagesstatus': null })).to.be.true;
expect(updateRecord.calledWithExactly({ 'count.messages': 3, 'messagesstatus': null })).to.be.true;
const statusCalls = updateRecord
.getCalls()
.filter((call) => 'messagesstatus' in call.args[0])
.map((call) => call.args[0].messagesstatus);
expect(statusCalls).to.deep.equal(['general/first', null, 'general/second', null]);
});
});
describe('post-processing', () => {
it('should increment the csv importer setting with the imported user count and notify the listeners', async () => {
const updatedSetting = { _id: 'CSV_Importer_Count', value: 7 };
incrementValueById.resolves(updatedSetting);
await runImport(fullArchive());
expect(incrementValueById.calledOnceWithExactly('CSV_Importer_Count', 2, { returnDocument: 'after' })).to.be.true;
expect(notifyOnSettingChanged.calledOnceWithExactly(updatedSetting)).to.be.true;
});
it('should not touch the csv importer setting when no user was parsed', async () => {
await runImport([{ name: 'general/messages.csv', content: CHANNEL_MESSAGES_CSV }]);
expect(incrementValueById.called).to.be.false;
});
it('should not notify the listeners when the setting update returned nothing', async () => {
await runImport([{ name: 'users.csv', content: USERS_CSV }]);
expect(notifyOnSettingChanged.called).to.be.false;
});
it('should add the import id to existing users that do not carry it yet', async () => {
findOneByUsername.withArgs('alice').resolves({ _id: 'user-alice', importIds: ['other'] });
findOneByUsername.withArgs('dana').resolves({ _id: 'user-dana' });
await runImport([{ name: 'general/messages.csv', content: 'alice,1700000000000,hello\ndana,1700000001000,hi' }]);
expect(addImportIds.calledTwice).to.be.true;
expect(addImportIds.calledWithExactly('user-alice', 'alice')).to.be.true;
expect(addImportIds.calledWithExactly('user-dana', 'dana')).to.be.true;
});
it('should reconcile a direct message recipient that never sent a message', async () => {
findOneByUsername.withArgs('bob').resolves({ _id: 'user-bob', importIds: [] });
await runImport([
{ name: 'users.csv', content: ' alice , alice@example.com , Alice Smith ' },
{ name: 'directmessages/messages.csv', content: 'alice,bob,1700000002000,dm from alice' },
]);
expect(findOneByUsername.calledOnceWithExactly('bob')).to.be.true;
expect(addImportIds.calledOnceWithExactly('user-bob', 'bob')).to.be.true;
});
it('should leave unknown users and users that already carry the import id untouched', async () => {
findOneByUsername.withArgs('carol').resolves({ _id: 'user-carol', importIds: ['carol'] });
await runImport([{ name: 'general/messages.csv', content: 'ghost,1700000000000,hello\ncarol,1700000001000,hi' }]);
expect(findOneByUsername.calledWithExactly('ghost')).to.be.true;
expect(findOneByUsername.calledWithExactly('carol')).to.be.true;
expect(addImportIds.called).to.be.false;
});
it('should add every parsed record to the import total and return the importer progress', async () => {
const { importer, progress } = await runImport(fullArchive());
expect(progress.count.total).to.equal(9);
expect(progress).to.equal(importer.progress);
});
it('should fail the import when the archive holds no valid record', async () => {
const { progress } = await runImport([{ name: '__MACOSX/ignored', content: '' }]);
expect(logger.error.calledWithMatch({ msg: 'No valid record found in the import file.' })).to.be.true;
expect(updateProgress.calledWith(ProgressStep.ERROR)).to.be.true;
expect(progress.step).to.equal(ProgressStep.ERROR);
});
it('should not fail the import when only contacts were found', async () => {
const { progress } = await runImport([{ name: 'contacts.csv', content: CONTACTS_CSV }]);
expect(updateProgress.calledWith(ProgressStep.ERROR)).to.be.false;
expect(progress.step).to.equal(ProgressStep.PREPARING_CONTACTS);
});
});
});

View File

@@ -0,0 +1,357 @@
import { EventEmitter } from 'events';
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
class MockIncomingMessage extends EventEmitter {
statusCode: number;
headers: Record<string, string>;
constructor(statusCode: number, headers: Record<string, string> = {}) {
super();
this.statusCode = statusCode;
this.headers = headers;
}
resume() {
// no-op
}
}
class MockClientRequest extends EventEmitter {
// no-op
}
const createMockHttpModule = (responseFactory: () => MockIncomingMessage, errorOnConnect = false) => ({
get: (_url: string, callback: (res: MockIncomingMessage) => void) => {
const req = new MockClientRequest();
if (errorOnConnect) {
setImmediate(() => req.emit('error', new Error('Connection refused')));
} else {
setImmediate(() => {
const res = responseFactory();
callback(res);
});
}
return req;
},
});
const createPendingFileImporter = (httpMock: ReturnType<typeof createMockHttpModule>) => {
const { PendingFileImporter } = proxyquire.noCallThru().load('../../../../../server/lib/import/pending-files/PendingFileImporter', {
'node:http': httpMock,
'node:https': httpMock,
'@rocket.chat/core-services': { api: { broadcast: sinon.stub() } },
'@rocket.chat/models': {
Messages: {
countAllImportedMessagesWithFilesToDownload: sinon.stub().resolves(0),
findAllImportedMessagesWithFilesToDownload: sinon.stub().returns([]),
setImportFileRocketChatAttachment: sinon.stub().resolves(),
},
Users: {
findOneById: sinon.stub().resolves(null),
},
},
'@rocket.chat/random': { Random: { id: () => 'random-id' } },
'../../media/file-upload': { FileUpload: { getStore: sinon.stub(), getPath: sinon.stub() } },
'../../../meteor-methods/messages/sendFileMessage': { parseFileIntoMessageAttachments: sinon.stub() },
'..': {
Importer: class MockImporter {
logger = { debug: sinon.stub(), error: sinon.stub(), info: sinon.stub() };
progress = { count: { total: 0 } };
reportProgress() {
// no-op
}
async updateProgress() {
// no-op
}
async updateRecord() {
// no-op
}
async addCountCompleted() {
// no-op
}
async addCountError() {
// no-op
}
getProgress() {
return this.progress;
}
isCursorNotFoundError() {
return false;
}
},
ProgressStep: {
PREPARING_STARTED: 'preparing_started',
IMPORTING_FILES: 'importing_files',
DONE: 'done',
ERROR: 'error',
},
},
});
const info = { key: 'pending-files', name: 'Pending Files' };
const importRecord = { _id: 'test-import' };
return new PendingFileImporter(info, importRecord, {});
};
describe('PendingFileImporter', () => {
describe('downloadFile', () => {
it('should resolve with buffer on successful 200 response', async () => {
const responseData = Buffer.from('file content');
const httpMock = createMockHttpModule(() => {
const res = new MockIncomingMessage(200, { 'content-type': 'image/png' });
setImmediate(() => {
res.emit('data', responseData);
res.emit('end');
});
return res;
});
const importer = createPendingFileImporter(httpMock);
const details: { type?: string } = {};
// Access private method through the instance
const result = await (importer as any).downloadFile('https://example.com/file.png', details);
expect(result).to.deep.equal(responseData);
expect(details.type).to.equal('image/png');
});
it('should reject on non-2xx status codes (e.g., 302 redirect)', async () => {
const httpMock = createMockHttpModule(() => {
const res = new MockIncomingMessage(302, { location: 'https://example.com/login' });
return res;
});
const importer = createPendingFileImporter(httpMock);
const details: { type?: string } = {};
try {
await (importer as any).downloadFile('https://example.com/file.png', details);
expect.fail('Should have thrown an error');
} catch (err: any) {
expect(err.message).to.include('Unexpected response status 302');
}
});
it('should reject on 404 status code', async () => {
const httpMock = createMockHttpModule(() => {
const res = new MockIncomingMessage(404);
return res;
});
const importer = createPendingFileImporter(httpMock);
const details: { type?: string } = {};
try {
await (importer as any).downloadFile('https://example.com/file.png', details);
expect.fail('Should have thrown an error');
} catch (err: any) {
expect(err.message).to.include('Unexpected response status 404');
}
});
it('should reject on 500 server error', async () => {
const httpMock = createMockHttpModule(() => {
const res = new MockIncomingMessage(500);
return res;
});
const importer = createPendingFileImporter(httpMock);
const details: { type?: string } = {};
try {
await (importer as any).downloadFile('https://example.com/file.png', details);
expect.fail('Should have thrown an error');
} catch (err: any) {
expect(err.message).to.include('Unexpected response status 500');
}
});
it('should reject on connection errors', async () => {
const httpMock = createMockHttpModule(
() => new MockIncomingMessage(200),
true, // errorOnConnect
);
const importer = createPendingFileImporter(httpMock);
const details: { type?: string } = {};
try {
await (importer as any).downloadFile('https://example.com/file.png', details);
expect.fail('Should have thrown an error');
} catch (err: any) {
expect(err.message).to.equal('Connection refused');
}
});
it('should reject on stream errors during download', async () => {
const httpMock = createMockHttpModule(() => {
const res = new MockIncomingMessage(200, { 'content-type': 'image/png' });
setImmediate(() => {
res.emit('data', Buffer.from('partial'));
res.emit('error', new Error('Stream error'));
});
return res;
});
const importer = createPendingFileImporter(httpMock);
const details: { type?: string } = {};
try {
await (importer as any).downloadFile('https://example.com/file.png', details);
expect.fail('Should have thrown an error');
} catch (err: any) {
expect(err.message).to.equal('Stream error');
}
});
it('should not set content-type if details already has a type', async () => {
const responseData = Buffer.from('file content');
const httpMock = createMockHttpModule(() => {
const res = new MockIncomingMessage(200, { 'content-type': 'text/html' });
setImmediate(() => {
res.emit('data', responseData);
res.emit('end');
});
return res;
});
const importer = createPendingFileImporter(httpMock);
const details: { type?: string } = { type: 'image/png' };
await (importer as any).downloadFile('https://example.com/file.png', details);
expect(details.type).to.equal('image/png');
});
it('should handle responses with undefined statusCode', async () => {
const httpMock = createMockHttpModule(() => {
const res = new MockIncomingMessage(undefined as any);
return res;
});
const importer = createPendingFileImporter(httpMock);
const details: { type?: string } = {};
try {
await (importer as any).downloadFile('https://example.com/file.png', details);
expect.fail('Should have thrown an error');
} catch (err: any) {
expect(err.message).to.include('Unexpected response status');
}
});
it('should accept status code 201', async () => {
const responseData = Buffer.from('created');
const httpMock = createMockHttpModule(() => {
const res = new MockIncomingMessage(201);
setImmediate(() => {
res.emit('data', responseData);
res.emit('end');
});
return res;
});
const importer = createPendingFileImporter(httpMock);
const details: { type?: string } = {};
const result = await (importer as any).downloadFile('https://example.com/file.png', details);
expect(result).to.deep.equal(responseData);
});
});
describe('getMessageAttachment', () => {
let importer: any;
beforeEach(() => {
const httpMock = createMockHttpModule(() => new MockIncomingMessage(200));
importer = createPendingFileImporter(httpMock);
});
it('should create image attachment for image types', () => {
const file = { _id: 'file-1', name: 'image.png', type: 'image/png', size: 1024 };
const url = '/file-upload/file-1/image.png';
const attachment = importer.getMessageAttachment(file, url);
expect(attachment).to.deep.equal({
title: 'image.png',
title_link: url,
image_url: url,
image_type: 'image/png',
image_size: 1024,
image_dimensions: undefined,
});
});
it('should create audio attachment for audio types', () => {
const file = { _id: 'file-1', name: 'audio.mp3', type: 'audio/mpeg', size: 2048 };
const url = '/file-upload/file-1/audio.mp3';
const attachment = importer.getMessageAttachment(file, url);
expect(attachment).to.deep.equal({
title: 'audio.mp3',
title_link: url,
audio_url: url,
audio_type: 'audio/mpeg',
audio_size: 2048,
});
});
it('should create video attachment for video types', () => {
const file = { _id: 'file-1', name: 'video.mp4', type: 'video/mp4', size: 4096 };
const url = '/file-upload/file-1/video.mp4';
const attachment = importer.getMessageAttachment(file, url);
expect(attachment).to.deep.equal({
title: 'video.mp4',
title_link: url,
video_url: url,
video_type: 'video/mp4',
video_size: 4096,
});
});
it('should create generic attachment for other types', () => {
const file = { _id: 'file-1', name: 'doc.pdf', type: 'application/pdf', size: 8192 };
const url = '/file-upload/file-1/doc.pdf';
const attachment = importer.getMessageAttachment(file, url);
expect(attachment).to.deep.equal({
title: 'doc.pdf',
title_link: url,
});
});
it('should create generic attachment for files without type', () => {
const file = { _id: 'file-1', name: 'unknown', size: 512 };
const url = '/file-upload/file-1/unknown';
const attachment = importer.getMessageAttachment(file, url);
expect(attachment).to.deep.equal({
title: 'unknown',
title_link: url,
});
});
});
});

View File

@@ -0,0 +1,298 @@
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const addMessageStub = sinon.stub().resolves();
const settingsStub = { get: sinon.stub() };
const { SlackImporter } = proxyquire.noCallThru().load('../../../../../server/lib/import/slack/SlackImporter', {
'@rocket.chat/models': {
'Messages': { find: sinon.stub().returns({ toArray: () => [] }) },
'Settings': { findOneById: sinon.stub().resolves(null) },
'ImportData': {},
'@global': true,
},
'..': {
Importer: class MockImporter {
logger = { debug: sinon.stub(), error: sinon.stub(), info: sinon.stub(), warn: sinon.stub() };
progress = { count: { total: 0 } };
converter = {
addMessage: addMessageStub,
addUser: sinon.stub().resolves(),
addChannel: sinon.stub().resolves(),
};
_useUpsert = false;
reportProgress() {
// no-op
}
async updateProgress() {
// no-op
}
async updateRecord() {
// no-op
}
getProgress() {
return this.progress;
}
},
ProgressStep: {
PREPARING_STARTED: 'preparing_started',
IMPORTING_USERS: 'importing_users',
IMPORTING_CHANNELS: 'importing_channels',
IMPORTING_MESSAGES: 'importing_messages',
DONE: 'done',
ERROR: 'error',
},
ImporterWebsocket: { progressUpdated: sinon.stub() },
},
'../../notifyListener': { notifyOnSettingChanged: sinon.stub() },
'../../../../app/mentions/lib/MentionsParser': {
MentionsParser: class {
getUserMentions() {
return [];
}
getChannelMentions() {
return [];
}
},
},
'../../../settings': { settings: settingsStub },
'../../utils/getUserAvatarURL': { getUserAvatarURL: sinon.stub().returns('/avatar/user') },
});
describe('SlackImporter', () => {
let importer: any;
beforeEach(() => {
addMessageStub.reset();
settingsStub.get.reset();
const info = { key: 'slack', name: 'Slack' };
const importRecord = { _id: 'test-import' };
importer = new SlackImporter(info, importRecord, {});
});
describe('processMessageSubType - file_share', () => {
it('should create hidden file message with empty msg for file_share subtype', async () => {
const message = {
type: 'message',
subtype: 'file_share',
ts: '1234567890.000001',
user: 'U12345',
file: {
id: 'F12345',
name: 'test.png',
url_private_download: 'https://files.slack.com/files/F12345/test.png?token=xoxe-123',
size: 1024,
is_external: false,
},
};
const slackChannelId = 'C12345';
const newMessage = {
_id: 'slack-C12345-1234567890-000001',
rid: slackChannelId,
ts: new Date(1234567890000),
msg: '',
u: { _id: 'U12345' },
};
await importer.processMessageSubType(message, slackChannelId, newMessage, {});
expect(addMessageStub.calledOnce).to.be.true;
const fileMessage = addMessageStub.firstCall.args[0];
expect(fileMessage._hidden).to.equal(true);
expect(fileMessage.msg).to.equal('');
expect(fileMessage._importFile).to.exist;
expect(fileMessage._importFile.downloadUrl).to.equal('https://files.slack.com/files/F12345/test.png?token=xoxe-123');
});
it('should not create file message if file has no download URL', async () => {
const message = {
type: 'message',
subtype: 'file_share',
ts: '1234567890.000001',
user: 'U12345',
file: {
id: 'F12345',
name: 'test.png',
size: 1024,
},
};
const slackChannelId = 'C12345';
const newMessage = {
_id: 'slack-C12345-1234567890-000001',
rid: slackChannelId,
ts: new Date(1234567890000),
msg: '',
u: { _id: 'U12345' },
};
await importer.processMessageSubType(message, slackChannelId, newMessage, {});
expect(addMessageStub.called).to.be.false;
});
it('should include thread message id if file is shared in a thread', async () => {
const message = {
type: 'message',
subtype: 'file_share',
ts: '1234567890.000002',
thread_ts: '1234567890.000001',
user: 'U12345',
file: {
id: 'F12345',
name: 'test.png',
url_private_download: 'https://files.slack.com/files/F12345/test.png',
size: 1024,
},
};
const slackChannelId = 'C12345';
const newMessage = {
_id: 'slack-C12345-1234567890-000002',
rid: slackChannelId,
ts: new Date(1234567890000),
msg: '',
u: { _id: 'U12345' },
};
await importer.processMessageSubType(message, slackChannelId, newMessage, {});
expect(addMessageStub.calledOnce).to.be.true;
const fileMessage = addMessageStub.firstCall.args[0];
expect(fileMessage.tmid).to.equal('slack-C12345-1234567890-000001');
expect(fileMessage._hidden).to.equal(true);
});
});
describe('prepareMessageObject - messages with files', () => {
it('should create hidden file messages for messages with files array', async () => {
const message = {
type: 'message',
ts: '1234567890.000001',
user: 'U12345',
text: 'Check out these files',
files: [
{
id: 'F12345',
name: 'image1.png',
url_private_download: 'https://files.slack.com/files/F12345/image1.png',
size: 1024,
},
{
id: 'F12346',
name: 'image2.png',
url_private_download: 'https://files.slack.com/files/F12346/image2.png',
size: 2048,
},
],
};
const slackChannelId = 'C12345';
await importer.prepareMessageObject(message, {}, slackChannelId);
const fileMessages = addMessageStub.getCalls().filter((call: any) => call.args[0]._importFile);
expect(fileMessages.length).to.equal(2);
fileMessages.forEach((call: any) => {
const fileMessage = call.args[0];
expect(fileMessage._hidden).to.equal(true);
expect(fileMessage.msg).to.equal('');
expect(fileMessage._importFile).to.exist;
});
});
it('should create unique file message IDs for multiple files', async () => {
const message = {
type: 'message',
ts: '1234567890.000001',
user: 'U12345',
text: 'Multiple files',
files: [
{ id: 'F1', name: 'file1.png', url_private_download: 'https://files.slack.com/F1', size: 100 },
{ id: 'F2', name: 'file2.png', url_private_download: 'https://files.slack.com/F2', size: 200 },
],
};
await importer.prepareMessageObject(message, {}, 'C12345');
const fileMessages = addMessageStub.getCalls().filter((call: any) => call.args[0]._importFile);
const ids = fileMessages.map((call: any) => call.args[0]._id);
expect(ids[0]).to.include('-file1');
expect(ids[1]).to.include('-file2');
expect(ids[0]).to.not.equal(ids[1]);
});
});
describe('convertSlackFileToPendingFile', () => {
it('should convert Slack file to pending file format', () => {
const slackFile = {
id: 'F12345',
name: 'document.pdf',
url_private_download: 'https://files.slack.com/files/F12345/document.pdf?token=xoxe-abc',
size: 4096,
is_external: false,
mimetype: 'application/pdf',
};
const pendingFile = importer.convertSlackFileToPendingFile(slackFile);
expect(pendingFile.id).to.equal('F12345');
expect(pendingFile.name).to.equal('document.pdf');
expect(pendingFile.downloadUrl).to.equal('https://files.slack.com/files/F12345/document.pdf?token=xoxe-abc');
expect(pendingFile.size).to.equal(4096);
expect(pendingFile.external).to.equal(false);
expect(pendingFile.source).to.equal('slack');
expect(pendingFile.original).to.deep.include(slackFile);
});
it('should handle external files', () => {
const slackFile = {
id: 'F99999',
name: 'external.pdf',
url_private_download: 'https://external.com/file.pdf',
size: 1000,
is_external: true,
};
const pendingFile = importer.convertSlackFileToPendingFile(slackFile);
expect(pendingFile.external).to.equal(true);
});
});
describe('makeSlackMessageId', () => {
it('should generate base message ID without file index', () => {
const id = importer.makeSlackMessageId('C12345', '1234567890.000001');
expect(id).to.equal('slack-C12345-1234567890-000001');
});
it('should generate message ID with file index', () => {
const id = importer.makeSlackMessageId('C12345', '1234567890.000001', '1');
expect(id).to.equal('slack-C12345-1234567890-000001-file1');
});
it('should replace dots with dashes in timestamp', () => {
const id = importer.makeSlackMessageId('CHANNEL', '123.456.789');
expect(id).to.equal('slack-CHANNEL-123-456-789');
});
});
});

View File

@@ -0,0 +1,259 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsStub = sinon.stub();
const modelsMock = {
Rooms: {
findOneByImportId: sinon.stub(),
},
};
const insertMessage = sinon.stub();
const { MessageConverter } = proxyquire.noCallThru().load('../../../../../server/lib/import/classes/converters/MessageConverter', {
'../../../messages/insertMessage': {
insertMessage,
},
'meteor/check': sinon.stub(),
'meteor/meteor': sinon.stub(),
'@rocket.chat/models': { ...modelsMock, '@global': true },
});
describe('Message Converter', () => {
beforeEach(() => {
modelsMock.Rooms.findOneByImportId.reset();
insertMessage.reset();
settingsStub.reset();
});
const messageToImport = {
ts: Date.now(),
u: {
_id: 'rocket.cat',
},
rid: 'general',
msg: 'testing',
};
describe('[insertMessage]', () => {
it('function should be called by the converter', async () => {
const converter = new MessageConverter({ workInMemory: true });
converter._cache.addRoom('general', 'general');
sinon.stub(converter, 'insertMessage');
sinon.stub(converter, 'resetLastMessages');
await converter.addObject(messageToImport);
await converter.convertData();
expect(converter.insertMessage.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.insertMessage.getCall(0).args).to.be.an('array').that.is.not.empty;
expect(converter.insertMessage.getCall(0).args[0]).to.be.deep.equal(messageToImport);
});
it('should call insertMessage lib function to save the message', async () => {
const converter = new MessageConverter({ workInMemory: true });
converter._cache.addRoom('general', 'main');
await (converter as any).insertMessage(messageToImport);
expect(insertMessage.getCalls()).to.be.an('array').with.lengthOf(1);
expect(insertMessage.getCall(0).args).to.be.an('array').with.lengthOf(4);
expect(insertMessage.getCall(0).args[0]).to.be.deep.equal({
_id: 'rocket.cat',
username: 'rocket.cat',
});
expect(insertMessage.getCall(0).args[1]).to.deep.include({
ts: messageToImport.ts,
msg: messageToImport.msg,
rid: 'main',
});
});
});
describe('[buildMessageObject]', () => {
it('should have the basic info', async () => {
const converter = new MessageConverter({ workInMemory: true });
const converted = await converter.buildMessageObject(messageToImport, 'general', { _id: 'rocket.cat', username: 'rocket.cat' });
expect(converted)
.to.be.an('object')
.that.deep.includes({
ts: messageToImport.ts,
msg: messageToImport.msg,
u: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
});
});
it('should not have properties with undefined values', async () => {
const converter = new MessageConverter({ workInMemory: true });
const converted = await converter.buildMessageObject(messageToImport, 'general', { _id: 'rocket.cat', username: 'rocket.cat' });
Object.entries(converted).forEach(([key, value]) => {
expect(value, `Property "${key}" should not be undefined`).to.not.be.undefined;
});
});
// #TODO: Validate all message attributes
it('should include _hidden field when present', async () => {
const converter = new MessageConverter({ workInMemory: true });
const hiddenMessage = {
...messageToImport,
_hidden: true,
};
const converted = await converter.buildMessageObject(hiddenMessage, 'general', { _id: 'rocket.cat', username: 'rocket.cat' });
expect(converted).to.have.property('_hidden', true);
});
it('should include _importFile field when present', async () => {
const converter = new MessageConverter({ workInMemory: true });
const importFile = {
id: 'F12345',
name: 'test.png',
downloadUrl: 'https://files.slack.com/F12345/test.png',
size: 1024,
source: 'slack',
};
const fileMessage = {
...messageToImport,
_hidden: true,
msg: '',
_importFile: importFile,
};
const converted = await converter.buildMessageObject(fileMessage, 'general', { _id: 'rocket.cat', username: 'rocket.cat' });
expect(converted).to.have.property('_hidden', true);
expect(converted).to.have.property('msg', '');
expect(converted).to.have.property('_importFile').that.deep.includes({
id: 'F12345',
name: 'test.png',
});
});
it('should not include _hidden field when not present or false', async () => {
const converter = new MessageConverter({ workInMemory: true });
const converted = await converter.buildMessageObject(messageToImport, 'general', { _id: 'rocket.cat', username: 'rocket.cat' });
expect(converted).to.not.have.property('_hidden');
});
});
describe('callbacks', () => {
it('beforeImportFn should be triggered', async () => {
const beforeImportFn = sinon.stub();
const converter = new MessageConverter({ workInMemory: true });
converter._cache.addRoom('general', 'general');
sinon.stub(converter, 'insertMessage');
sinon.stub(converter, 'resetLastMessages');
await converter.addObject(messageToImport);
await converter.convertData({
beforeImportFn,
});
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
});
it('afterImportFn should be triggered', async () => {
const afterImportFn = sinon.stub();
const converter = new MessageConverter({ workInMemory: true });
converter._cache.addRoom('general', 'general');
sinon.stub(converter, 'insertMessage');
sinon.stub(converter, 'resetLastMessages');
await converter.addObject(messageToImport);
await converter.convertData({
afterImportFn,
});
expect(converter.insertMessage.getCalls()).to.be.an('array').with.lengthOf(1);
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
});
it('should skip record if beforeImportFn returns false', async () => {
let recordId = null;
const beforeImportFn = sinon.stub();
const afterImportFn = sinon.stub();
beforeImportFn.callsFake((record) => {
recordId = record._id;
return false;
});
const converter = new MessageConverter({ workInMemory: true });
converter._cache.addRoom('general', 'general');
sinon.stub(converter, 'insertMessage');
sinon.stub(converter, 'resetLastMessages');
sinon.stub(converter, 'skipRecord');
await converter.addObject(messageToImport);
await converter.convertData({
beforeImportFn,
afterImportFn,
});
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(0);
expect(converter.skipRecord.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.skipRecord.getCall(0).args).to.be.an('array').that.is.deep.equal([recordId]);
expect(converter.insertMessage.getCalls()).to.be.an('array').with.lengthOf(0);
});
it('should not skip record if beforeImportFn returns true', async () => {
const beforeImportFn = sinon.stub();
const afterImportFn = sinon.stub();
beforeImportFn.callsFake(() => true);
const converter = new MessageConverter({ workInMemory: true });
converter._cache.addRoom('general', 'general');
sinon.stub(converter, 'insertMessage');
sinon.stub(converter, 'resetLastMessages');
sinon.stub(converter, 'skipRecord');
await converter.addObject(messageToImport);
await converter.convertData({
beforeImportFn,
afterImportFn,
});
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.skipRecord.getCalls()).to.be.an('array').with.lengthOf(0);
expect(converter.insertMessage.getCalls()).to.be.an('array').with.lengthOf(1);
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
});
it('onErrorFn should be triggered if mandatory attributes are missing', async () => {
const converter = new MessageConverter({ workInMemory: true });
converter._cache.addRoom('general', 'general');
sinon.stub(converter, 'resetLastMessages');
const onErrorFn = sinon.stub();
sinon.stub(converter, 'saveError');
await converter.addObject({});
await converter.convertData({ onErrorFn });
expect(onErrorFn.getCall(0)).to.not.be.null;
expect(converter.saveError.getCall(0)).to.not.be.null;
});
});
});

View File

@@ -0,0 +1,134 @@
import type { IImportRecord, IImportRecordType } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsStub = sinon.stub();
const modelsMock = {
ImportData: {
find: sinon.stub(),
updateOne: sinon.stub(),
col: {
insertOne: sinon.stub(),
},
},
};
const { RecordConverter } = proxyquire.noCallThru().load('../../../../../server/lib/import/classes/converters/RecordConverter', {
'meteor/check': sinon.stub(),
'meteor/meteor': sinon.stub(),
'@rocket.chat/models': { ...modelsMock, '@global': true },
});
class TestConverter extends RecordConverter<IImportRecord> {
constructor(workInMemory = true) {
super({ workInMemory });
}
protected getDataType(): IImportRecordType {
return 'user';
}
}
describe('Record Converter', () => {
const userToImport = {
name: 'user1',
emails: ['user1@domain.com'],
importIds: ['importId1'],
username: 'username1',
};
describe('Working with Mongo Collection', () => {
beforeEach(() => {
modelsMock.ImportData.col.insertOne.reset();
modelsMock.ImportData.find.reset();
modelsMock.ImportData.updateOne.reset();
modelsMock.ImportData.find.callsFake(() => ({ toArray: () => [] }));
});
describe('Adding and Retrieving users', () => {
it('should store objects in the collection', async () => {
const converter = new TestConverter(false);
await converter.addObject(userToImport);
expect(modelsMock.ImportData.col.insertOne.getCall(0)).to.not.be.null;
});
it('should read objects from the collection', async () => {
const converter = new TestConverter(false);
await converter.addObject(userToImport);
await converter.getDataToImport();
expect(modelsMock.ImportData.find.getCall(0)).to.not.be.null;
});
it('should flag skipped records on the document', async () => {
const converter = new TestConverter(false);
await (converter as any).skipRecord('skippedId');
expect(modelsMock.ImportData.updateOne.getCall(0)).to.not.be.null;
expect(modelsMock.ImportData.updateOne.getCall(0).args).to.be.an('array').that.deep.contains({ _id: 'skippedId' });
});
it('should store error information on the document', async () => {
const converter = new TestConverter(false);
await (converter as any).saveError('errorId', new Error());
expect(modelsMock.ImportData.updateOne.getCall(0)).to.not.be.null;
expect(modelsMock.ImportData.updateOne.getCall(0).args).to.be.an('array').that.deep.contains({ _id: 'errorId' });
});
});
});
describe('Working in Memory', () => {
beforeEach(() => {
modelsMock.ImportData.col.insertOne.reset();
modelsMock.ImportData.updateOne.reset();
modelsMock.ImportData.find.reset();
settingsStub.reset();
});
describe('Adding and Retrieving users', () => {
it('should not store objects in the collection', async () => {
const converter = new TestConverter(true);
await converter.addObject(userToImport);
expect(modelsMock.ImportData.col.insertOne.getCall(0)).to.be.null;
});
it('should not try to read objects from the collection', async () => {
const converter = new TestConverter(true);
await converter.addObject(userToImport);
await converter.getDataToImport();
expect(modelsMock.ImportData.find.getCall(0)).to.be.null;
});
it('should properly retrieve the data added to memory', async () => {
const converter = new TestConverter(true);
await converter.addObject(userToImport);
const dataToImport = await converter.getDataToImport();
expect(dataToImport.length).to.be.equal(1);
expect(dataToImport[0].data).to.be.equal(userToImport);
});
it('should not access the collection when flagging skipped records', async () => {
const converter = new TestConverter(true);
await (converter as any).skipRecord('skippedId');
expect(modelsMock.ImportData.updateOne.getCall(0)).to.be.null;
});
it('should not access the collection when storing error information', async () => {
const converter = new TestConverter(true);
await (converter as any).saveError('errorId', new Error());
expect(modelsMock.ImportData.updateOne.getCall(0)).to.be.null;
});
});
});
});

View File

@@ -0,0 +1,269 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsStub = sinon.stub();
const modelsMock = {
Rooms: {
archiveById: sinon.stub(),
updateOne: sinon.stub(),
findOneById: sinon.stub(),
findDirectRoomContainingAllUsernames: sinon.stub(),
findOneByNonValidatedName: sinon.stub(),
},
Subscriptions: {
archiveByRoomId: sinon.stub(),
},
};
const createDirectMessage = sinon.stub();
const saveRoomSettings = sinon.stub();
const { RoomConverter } = proxyquire.noCallThru().load('../../../../../server/lib/import/classes/converters/RoomConverter', {
'../../../../meteor-methods/messages/createDirectMessage': {
createDirectMessage,
},
'../../../../meteor-methods/rooms/saveRoomSettings': {
saveRoomSettings,
},
'../../../notifyListener': {
notifyOnSubscriptionChangedByRoomId: sinon.stub(),
},
'../../../../meteor-methods/rooms/createChannel': {
createChannelMethod: sinon.stub(),
},
'../../../../meteor-methods/rooms/createPrivateGroup': {
createPrivateGroupMethod: sinon.stub(),
},
'meteor/check': sinon.stub(),
'meteor/meteor': sinon.stub(),
'@rocket.chat/models': { ...modelsMock, '@global': true },
});
describe('Room Converter', () => {
beforeEach(() => {
modelsMock.Rooms.archiveById.reset();
modelsMock.Rooms.updateOne.reset();
modelsMock.Rooms.findOneById.reset();
modelsMock.Rooms.findDirectRoomContainingAllUsernames.reset();
modelsMock.Rooms.findOneByNonValidatedName.reset();
modelsMock.Subscriptions.archiveByRoomId.reset();
createDirectMessage.reset();
saveRoomSettings.reset();
settingsStub.reset();
});
const roomToImport = {
name: 'room1',
importIds: ['importIdRoom1'],
};
describe('[findExistingRoom]', () => {
it('function should be called by the converter', async () => {
const converter = new RoomConverter({ workInMemory: true });
sinon.stub(converter, 'findExistingRoom');
sinon.stub(converter, 'insertOrUpdateRoom');
await converter.addObject(roomToImport);
await converter.convertChannels('startedByUserId');
expect(converter.findExistingRoom.getCall(0)).to.not.be.null;
});
it('should search by name', async () => {
const converter = new RoomConverter({ workInMemory: true });
await converter.findExistingRoom(roomToImport);
expect(modelsMock.Rooms.findOneByNonValidatedName.getCalls()).to.be.an('array').with.lengthOf(1);
expect(modelsMock.Rooms.findOneByNonValidatedName.getCall(0).args).to.be.an('array').that.contains('room1');
});
it('should not search by name if there is none', async () => {
const converter = new RoomConverter({ workInMemory: true });
await converter.findExistingRoom({});
expect(modelsMock.Rooms.findOneByNonValidatedName.getCalls()).to.be.an('array').with.lengthOf(0);
});
it('should search DMs by usernames', async () => {
const converter = new RoomConverter({ workInMemory: true });
converter._cache.addUser('importId1', 'userId1', 'username1');
converter._cache.addUser('importId2', 'userId2', 'username2');
await converter.findExistingRoom({
t: 'd',
users: ['importId1', 'importId2'],
importIds: ['importIdRoom1'],
});
expect(modelsMock.Rooms.findDirectRoomContainingAllUsernames.getCalls()).to.be.an('array').with.lengthOf(1);
expect(modelsMock.Rooms.findDirectRoomContainingAllUsernames.getCall(0).args)
.to.be.an('array')
.that.deep.includes(['username1', 'username2']);
});
});
describe('[insertRoom]', () => {
it('function should be called by the converter', async () => {
const converter = new RoomConverter({ workInMemory: true });
sinon.stub(converter, 'findExistingRoom');
sinon.stub(converter, 'insertRoom');
sinon.stub(converter, 'updateRoom');
await converter.addObject(roomToImport);
await converter.convertChannels('startedByUserId');
expect(converter.updateRoom.getCalls()).to.be.an('array').with.lengthOf(0);
expect(converter.insertRoom.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.insertRoom.getCall(0).args).to.be.an('array').that.is.not.empty;
expect(converter.insertRoom.getCall(0).args[0]).to.be.deep.equal(roomToImport);
});
it('function should not be called for existing rooms', async () => {
const converter = new RoomConverter({ workInMemory: true });
sinon.stub(converter, 'findExistingRoom');
converter.findExistingRoom.returns({ _id: 'oldId' });
sinon.stub(converter, 'insertRoom');
sinon.stub(converter, 'updateRoom');
await converter.addObject(roomToImport);
await converter.convertChannels('startedByUserId');
expect(converter.insertRoom.getCall(0)).to.be.null;
});
it('should call createDirectMessage to create DM rooms', async () => {
const converter = new RoomConverter({ workInMemory: true });
sinon.stub(converter, 'updateRoomId');
createDirectMessage.callsFake((_options, data) => {
return {
...data,
_id: 'Id1',
};
});
converter._cache.addUser('importId1', 'userId1', 'username1');
converter._cache.addUser('importId2', 'userId2', 'username2');
await (converter as any).insertRoom(
{
t: 'd',
users: ['importId1', 'importId2'],
importIds: ['importIdRoom1'],
},
'startedByUserId',
);
expect(createDirectMessage.getCalls()).to.be.an('array').with.lengthOf(1);
expect(createDirectMessage.getCall(0).args).to.be.an('array').with.lengthOf(3).that.deep.includes(['username1', 'username2']);
});
// #TODO: Validate all room types
});
describe('callbacks', () => {
it('beforeImportFn should be triggered', async () => {
const beforeImportFn = sinon.stub();
const converter = new RoomConverter({ workInMemory: true });
sinon.stub(converter, 'findExistingRoom');
sinon.stub(converter, 'insertOrUpdateRoom');
await converter.addObject(roomToImport);
await converter.convertChannels('startedByUserId', {
beforeImportFn,
});
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
});
it('afterImportFn should be triggered', async () => {
const afterImportFn = sinon.stub();
const converter = new RoomConverter({ workInMemory: true });
sinon.stub(converter, 'findExistingRoom');
sinon.stub(converter, 'insertOrUpdateRoom');
await converter.addObject(roomToImport);
await converter.convertChannels('startedByUserId', {
afterImportFn,
});
expect(converter.insertOrUpdateRoom.getCalls()).to.be.an('array').with.lengthOf(1);
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
});
it('should skip record if beforeImportFn returns false', async () => {
let recordId = null;
const beforeImportFn = sinon.stub();
const afterImportFn = sinon.stub();
beforeImportFn.callsFake((record) => {
recordId = record._id;
return false;
});
const converter = new RoomConverter({ workInMemory: true });
sinon.stub(converter, 'findExistingRoom');
sinon.stub(converter, 'insertOrUpdateRoom');
sinon.stub(converter, 'skipRecord');
await converter.addObject(roomToImport);
await converter.convertChannels('startedByUserId', {
beforeImportFn,
afterImportFn,
});
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(0);
expect(converter.skipRecord.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.skipRecord.getCall(0).args).to.be.an('array').that.is.deep.equal([recordId]);
expect(converter.insertOrUpdateRoom.getCalls()).to.be.an('array').with.lengthOf(0);
});
it('should not skip record if beforeImportFn returns true', async () => {
const beforeImportFn = sinon.stub();
const afterImportFn = sinon.stub();
beforeImportFn.callsFake(() => true);
const converter = new RoomConverter({ workInMemory: true });
sinon.stub(converter, 'findExistingRoom');
sinon.stub(converter, 'insertOrUpdateRoom');
sinon.stub(converter, 'skipRecord');
await converter.addObject(roomToImport);
await converter.convertChannels('startedByUserId', {
beforeImportFn,
afterImportFn,
});
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.skipRecord.getCalls()).to.be.an('array').with.lengthOf(0);
expect(converter.insertOrUpdateRoom.getCalls()).to.be.an('array').with.lengthOf(1);
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
});
it('onErrorFn should be triggered if there is no name and is not a DM', async () => {
const converter = new RoomConverter({ workInMemory: true });
const onErrorFn = sinon.stub();
sinon.stub(converter, 'findExistingRoom');
sinon.stub(converter, 'insertOrUpdateRoom');
sinon.stub(converter, 'saveError');
await converter.addObject({});
await converter.convertChannels('startedByUserId', { onErrorFn });
expect(converter.insertOrUpdateRoom.getCall(0)).to.be.null;
expect(onErrorFn.getCall(0)).to.not.be.null;
expect(converter.saveError.getCall(0)).to.not.be.null;
});
});
});

View File

@@ -0,0 +1,625 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsStub = sinon.stub();
const modelsMock = {
Users: {
findOneByEmailAddress: sinon.stub(),
findOneByUsernameIgnoringCase: sinon.stub(),
findOneById: sinon.stub(),
},
};
const addUserToDefaultChannels = sinon.stub();
const generateUsernameSuggestion = sinon.stub();
const insertUserDoc = sinon.stub();
const callbacks = {
run: sinon.stub(),
};
const bcryptHash = sinon.stub();
const sha = sinon.stub();
const generateTempPassword = sinon.stub();
const { UserConverter } = proxyquire.noCallThru().load('../../../../../server/lib/import/classes/converters/UserConverter', {
'../../../callbacks': {
callbacks,
},
'../../../rooms/addUserToDefaultChannels': {
addUserToDefaultChannels,
},
'../../../users/getUsernameSuggestion': {
generateUsernameSuggestion,
},
'../../../users/saveUserIdentity': {
saveUserIdentity: sinon.stub(),
},
'../../../users/setUserActiveStatus': {
setUserActiveStatus: sinon.stub(),
},
'../../../notifyListener': {
notifyOnUserChange: sinon.stub(),
},
'./generateTempPassword': {
generateTempPassword,
'@global': true,
},
'bcrypt': {
'hash': bcryptHash,
'@global': true,
},
'meteor/check': sinon.stub(),
'meteor/meteor': sinon.stub(),
'@rocket.chat/sha256': {
SHA256: sha,
},
'meteor/accounts-base': {
Accounts: {
insertUserDoc,
_bcryptRounds: () => 10,
},
},
'@rocket.chat/models': { ...modelsMock, '@global': true },
});
describe('User Converter', () => {
beforeEach(() => {
modelsMock.Users.findOneByEmailAddress.reset();
modelsMock.Users.findOneByUsernameIgnoringCase.reset();
modelsMock.Users.findOneById.reset();
callbacks.run.reset();
insertUserDoc.reset();
addUserToDefaultChannels.reset();
generateUsernameSuggestion.reset();
settingsStub.reset();
});
const userToImport = {
name: 'user1',
emails: ['user1@domain.com'],
importIds: ['importId1'],
username: 'username1',
};
describe('[findExistingUser]', () => {
it('function should be called by the converter', async () => {
const converter = new UserConverter({ workInMemory: true });
const findExistingUser = sinon.stub(converter, 'findExistingUser');
findExistingUser.throws();
await converter.addObject(userToImport);
await converter.convertData();
expect(findExistingUser.getCall(0)).to.not.be.null;
expect(callbacks.run.getCall(0)).to.not.be.null;
expect(callbacks.run.getCall(0).args).to.be.deep.equal(['afterUserImport', { inserted: [], updated: [], skipped: 0, failed: 1 }]);
});
it('should search by email address', async () => {
const converter = new UserConverter({ workInMemory: true });
await converter.findExistingUser(userToImport);
expect(modelsMock.Users.findOneByEmailAddress.getCalls()).to.be.an('array').with.lengthOf(1);
expect(modelsMock.Users.findOneByEmailAddress.getCall(0).args).to.be.an('array').that.contains('user1@domain.com');
});
it('should search by username', async () => {
const converter = new UserConverter({ workInMemory: true });
await converter.findExistingUser(userToImport);
expect(modelsMock.Users.findOneByUsernameIgnoringCase.getCalls()).to.be.an('array').with.lengthOf(1);
expect(modelsMock.Users.findOneByUsernameIgnoringCase.getCall(0).args).to.be.an('array').that.contains('username1');
});
it('should not search by username if an user is found by email', async () => {
const converter = new UserConverter({ workInMemory: true });
modelsMock.Users.findOneByEmailAddress.resolves(userToImport);
await converter.findExistingUser(userToImport);
expect(modelsMock.Users.findOneByUsernameIgnoringCase.getCall(0)).to.be.null;
});
});
describe('[buildNewUserObject]', () => {
const mappedUser = (expectedData: Record<string, any>) => ({
type: 'user',
services: {
password: {
bcrypt: 'hashed=tempPassword',
},
},
...expectedData,
});
const converter = new UserConverter({ workInMemory: true });
const hashPassword = sinon.stub(converter, 'hashPassword');
generateTempPassword.returns('tempPassword');
hashPassword.callsFake(async (pass) => `hashed=${pass}`);
bcryptHash.callsFake((pass: string) => `hashed=${pass}`);
sha.callsFake((pass: string) => pass);
it('should map an empty object', async () => {
expect(
await converter.buildNewUserObject({
emails: [],
importIds: [],
}),
).to.be.deep.equal(mappedUser({}));
});
it('should map the name and username', async () => {
expect(
await converter.buildNewUserObject({
emails: [],
importIds: [],
name: 'name1',
username: 'username1',
}),
).to.be.deep.equal(
mappedUser({
username: 'username1',
name: 'name1',
}),
);
});
it('should map optional fields', async () => {
expect(
await converter.buildNewUserObject({
emails: [],
importIds: [],
statusText: 'statusText1',
bio: 'bio1',
avatarUrl: 'avatarUrl',
utcOffset: 3,
}),
).to.be.deep.equal(
mappedUser({
statusText: 'statusText1',
bio: 'bio1',
_pendingAvatarUrl: 'avatarUrl',
utcOffset: 3,
}),
);
});
it('should map custom fields', async () => {
expect(
await (converter as any).buildNewUserObject({
emails: [],
importIds: [],
customFields: {
age: 32,
nickname: 'stitch',
},
}),
).to.be.deep.equal(
mappedUser({
customFields: {
age: 32,
nickname: 'stitch',
},
}),
);
});
it('should not map roles', async () => {
expect(
await converter.buildNewUserObject({
emails: [],
importIds: [],
roles: ['role1'],
}),
).to.be.deep.equal(mappedUser({}));
});
it('should map identifiers', async () => {
expect(
await converter.buildNewUserObject({
name: 'user1',
emails: ['user1@domain.com'],
importIds: ['importId1'],
username: 'username1',
}),
).to.be.deep.equal(
mappedUser({
username: 'username1',
name: 'user1',
importIds: ['importId1'],
emails: [{ address: 'user1@domain.com', verified: false }],
}),
);
});
it('should map password', async () => {
expect(
await converter.buildNewUserObject({
emails: [],
importIds: [],
password: 'batata',
}),
).to.be.deep.equal(
mappedUser({
services: {
password: {
bcrypt: 'hashed=batata',
},
},
}),
);
});
it('should map ldap service data', async () => {
expect(
await converter.buildNewUserObject({
emails: [],
importIds: [],
services: {
ldap: {
id: 'id',
},
},
}),
).to.be.deep.equal(
mappedUser({
services: {
ldap: {
id: 'id',
},
},
ldap: true,
}),
);
});
it('should map deleted users', async () => {
expect(
await converter.buildNewUserObject({
emails: [],
importIds: [],
deleted: true,
}),
).to.be.deep.equal(
mappedUser({
active: false,
}),
);
});
it('should map restored users', async () => {
expect(
await converter.buildNewUserObject({
emails: [],
importIds: [],
deleted: false,
}),
).to.be.deep.equal(
mappedUser({
active: true,
}),
);
});
it('should map user type', async () => {
expect(
await converter.buildNewUserObject({
emails: [],
importIds: [],
type: 'user',
}),
).to.be.deep.equal(mappedUser({}));
});
it('should map bot type', async () => {
expect(
await converter.buildNewUserObject({
emails: [],
importIds: [],
type: 'bot',
}),
).to.be.deep.equal(
mappedUser({
type: 'bot',
services: {
password: {
bcrypt: 'hashed=tempPassword',
},
},
}),
);
});
});
describe('[insertUser]', () => {
it('function should be called by the converter', async () => {
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
modelsMock.Users.findOneByEmailAddress.resolves(null);
modelsMock.Users.findOneByUsernameIgnoringCase.resolves(null);
sinon.stub(converter, 'insertUser');
sinon.stub(converter, 'updateUser');
await converter.addObject(userToImport);
await converter.convertData();
expect(converter.updateUser.getCalls()).to.be.an('array').with.lengthOf(0);
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.insertUser.getCall(0).args).to.be.an('array').that.is.not.empty;
expect(converter.insertUser.getCall(0).args[0]).to.be.deep.equal(userToImport);
expect(addUserToDefaultChannels.getCalls()).to.be.an('array').with.lengthOf(0);
});
it('function should not be called when skipNewUsers = true', async () => {
const converter = new UserConverter({ workInMemory: true, skipNewUsers: true });
sinon.stub(converter, 'findExistingUser');
sinon.stub(converter, 'insertUser');
sinon.stub(converter, 'updateUser');
sinon.stub(converter, 'skipMemoryRecord');
await converter.addObject(userToImport);
await converter.convertData();
expect(converter.insertUser.getCall(0)).to.be.null;
expect(converter.skipMemoryRecord.getCall(0)).to.not.be.null;
expect(callbacks.run.getCall(0)).to.not.be.null;
expect(callbacks.run.getCall(0).args).to.be.deep.equal(['afterUserImport', { inserted: [], updated: [], skipped: 1, failed: 0 }]);
});
it('function should not be called for existing users', async () => {
const converter = new UserConverter({ workInMemory: true });
sinon.stub(converter, 'findExistingUser');
converter.findExistingUser.returns({ _id: 'oldId' });
sinon.stub(converter, 'insertUser');
sinon.stub(converter, 'updateUser');
await converter.addObject(userToImport);
await converter.convertData();
expect(converter.insertUser.getCall(0)).to.be.null;
expect(callbacks.run.getCall(0)).to.not.be.null;
expect(callbacks.run.getCall(0).args).to.be.deep.equal([
'afterUserImport',
{ inserted: [], updated: ['oldId'], skipped: 0, failed: 0 },
]);
});
it('addUserToDefaultChannels should be called by the converter on successful insert', async () => {
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: false });
modelsMock.Users.findOneByEmailAddress.resolves(null);
modelsMock.Users.findOneByUsernameIgnoringCase.resolves(null);
modelsMock.Users.findOneById.withArgs('newId').returns({ newUser: true });
sinon.stub(converter, 'insertUser');
converter.insertUser.callsFake(() => 'newId');
await converter.addObject(userToImport);
await converter.convertData();
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.insertUser.getCall(0).args).to.be.an('array').that.is.not.empty;
expect(converter.insertUser.getCall(0).args[0]).to.be.deep.equal(userToImport);
expect(addUserToDefaultChannels.getCalls()).to.be.an('array').with.lengthOf(1);
expect(addUserToDefaultChannels.getCall(0).args).to.be.an('array').that.deep.contains({ newUser: true });
});
it('should call insertUserDoc with the mapped data and roles', async () => {
const converter = new UserConverter({ workInMemory: true });
let insertedUser = null;
insertUserDoc.callsFake((_options, data) => {
insertedUser = {
...data,
_id: 'Id1',
};
return 'Id1';
});
modelsMock.Users.findOneById.withArgs('Id1').resolves(insertedUser);
await (converter as any).insertUser({ ...userToImport, roles: ['role1', 'role2'] });
expect(insertUserDoc.getCalls()).to.be.an('array').with.lengthOf(1);
expect(insertUserDoc.getCall(0).args).to.be.an('array').with.lengthOf(2);
const usedParams = insertUserDoc.getCall(0).args[1];
expect(usedParams).to.deep.include({
type: 'user',
username: 'username1',
name: 'user1',
importIds: ['importId1'],
emails: [{ address: 'user1@domain.com', verified: false }],
globalRoles: ['role1', 'role2'],
});
});
});
describe('[updateUser]', () => {
it('function should be called by the converter', async () => {
const converter = new UserConverter({ workInMemory: true });
sinon.stub(converter, 'findExistingUser');
converter.findExistingUser.returns({ _id: 'oldId' });
sinon.stub(converter, 'insertUser');
sinon.stub(converter, 'updateUser');
await converter.addObject(userToImport);
await converter.convertData();
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(0);
expect(converter.updateUser.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.updateUser.getCall(0).args).to.be.an('array').that.is.not.empty;
expect(converter.updateUser.getCall(0).args[1]).to.be.deep.equal(userToImport);
});
it('function should not be called when skipExistingUsers = true', async () => {
const converter = new UserConverter({ workInMemory: true, skipExistingUsers: true });
sinon.stub(converter, 'findExistingUser');
converter.findExistingUser.returns({ _id: 'oldId' });
sinon.stub(converter, 'insertUser');
sinon.stub(converter, 'updateUser');
sinon.stub(converter, 'skipMemoryRecord');
await converter.addObject(userToImport);
await converter.convertData();
expect(converter.updateUser.getCall(0)).to.be.null;
expect(converter.skipMemoryRecord.getCall(0)).to.not.be.null;
expect(callbacks.run.getCall(0)).to.not.be.null;
expect(callbacks.run.getCall(0).args).to.be.deep.equal(['afterUserImport', { inserted: [], updated: [], skipped: 1, failed: 0 }]);
});
it('function should not be called for new users', async () => {
const converter = new UserConverter({ workInMemory: true });
sinon.stub(converter, 'findExistingUser');
sinon.stub(converter, 'insertUser');
sinon.stub(converter, 'updateUser');
await converter.addObject(userToImport);
await converter.convertData();
expect(converter.updateUser.getCall(0)).to.be.null;
});
});
// #TODO: Validate batch conversions
describe('callbacks', () => {
it('beforeImportFn should be triggered', async () => {
const beforeImportFn = sinon.stub();
beforeImportFn.callsFake(() => true);
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
sinon.stub(converter, 'findExistingUser');
sinon.stub(converter, 'insertUser');
sinon.stub(converter, 'updateUser');
await converter.addObject(userToImport);
await converter.convertData({
beforeImportFn,
});
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(1);
});
it('afterImportFn should be triggered', async () => {
const afterImportFn = sinon.stub();
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
sinon.stub(converter, 'findExistingUser');
sinon.stub(converter, 'insertUser');
sinon.stub(converter, 'updateUser');
await converter.addObject(userToImport);
await converter.convertData({
afterImportFn,
});
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(1);
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
});
it('should skip record if beforeImportFn returns false', async () => {
let recordId = null;
const beforeImportFn = sinon.stub();
const afterImportFn = sinon.stub();
beforeImportFn.callsFake((record) => {
recordId = record._id;
return false;
});
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
sinon.stub(converter, 'findExistingUser');
sinon.stub(converter, 'insertUser');
sinon.stub(converter, 'updateUser');
sinon.stub(converter, 'skipMemoryRecord');
await converter.addObject(userToImport);
await converter.convertData({
beforeImportFn,
afterImportFn,
});
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.skipMemoryRecord.getCalls()).to.be.an('array').with.lengthOf(1);
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(0);
expect(converter.skipMemoryRecord.getCall(0).args).to.be.an('array').that.is.deep.equal([recordId]);
expect(callbacks.run.getCall(0)).to.not.be.null;
expect(callbacks.run.getCall(0).args).to.be.deep.equal(['afterUserImport', { inserted: [], updated: [], skipped: 1, failed: 0 }]);
});
it('should not skip record if beforeImportFn returns true', async () => {
let userId = null;
const beforeImportFn = sinon.stub();
const afterImportFn = sinon.stub();
beforeImportFn.callsFake(() => true);
afterImportFn.callsFake((record) => {
userId = record.data._id;
});
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
sinon.stub(converter, 'findExistingUser');
sinon.stub(converter, 'insertUser');
sinon.stub(converter, 'updateUser');
await converter.addObject(userToImport);
await converter.convertData({
beforeImportFn,
afterImportFn,
});
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(1);
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
expect(callbacks.run.getCall(0)).to.not.be.null;
expect(callbacks.run.getCall(0).args).to.be.deep.equal([
'afterUserImport',
{ inserted: [userId], updated: [], skipped: 0, failed: 0 },
]);
});
it('onErrorFn should be triggered if there is no email and no username', async () => {
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
const onErrorFn = sinon.stub();
sinon.stub(converter, 'findExistingUser');
sinon.stub(converter, 'insertUser');
sinon.stub(converter, 'updateUser');
sinon.stub(converter, 'saveError');
await converter.addObject({
name: 'user1',
emails: [],
importIds: [],
});
await converter.convertData({ onErrorFn });
expect(converter.insertUser.getCall(0)).to.be.null;
expect(callbacks.run.getCall(0)).to.not.be.null;
expect(callbacks.run.getCall(0).args).to.be.deep.equal(['afterUserImport', { inserted: [], updated: [], skipped: 0, failed: 1 }]);
expect(onErrorFn.getCall(0)).to.not.be.null;
expect(converter.saveError.getCall(0)).to.not.be.null;
});
// #TODO: Validate afterBatchFn
});
});

View File

@@ -0,0 +1,396 @@
import type { Root } from '@rocket.chat/message-parser';
import { expect } from 'chai';
import { extractMentionsFromMessageAST } from '../../../../../server/lib/messages/extractMentionsFromMessageAST';
describe('extractMentionsFromMessageAST', () => {
it('should return empty arrays when AST has no mentions', () => {
const ast: Root = [
{
type: 'PARAGRAPH',
value: [
{
type: 'PLAIN_TEXT',
value: 'Hello world',
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.be.an('array').that.is.empty;
expect(result.channels).to.be.an('array').that.is.empty;
});
it('should extract user mentions from AST', () => {
const ast: Root = [
{
type: 'PARAGRAPH',
value: [
{
type: 'PLAIN_TEXT',
value: 'Hello ',
},
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'john.doe',
},
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.deep.equal(['john.doe']);
expect(result.channels).to.be.an('array').that.is.empty;
});
it('should extract channel mentions from AST', () => {
const ast: Root = [
{
type: 'PARAGRAPH',
value: [
{
type: 'PLAIN_TEXT',
value: 'Check ',
},
{
type: 'MENTION_CHANNEL',
value: {
type: 'PLAIN_TEXT',
value: 'general',
},
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.be.an('array').that.is.empty;
expect(result.channels).to.deep.equal(['general']);
});
it('should extract both user and channel mentions', () => {
const ast: Root = [
{
type: 'PARAGRAPH',
value: [
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'admin',
},
},
{
type: 'PLAIN_TEXT',
value: ' please check ',
},
{
type: 'MENTION_CHANNEL',
value: {
type: 'PLAIN_TEXT',
value: 'support',
},
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.deep.equal(['admin']);
expect(result.channels).to.deep.equal(['support']);
});
it('should extract multiple user mentions', () => {
const ast: Root = [
{
type: 'PARAGRAPH',
value: [
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'user1',
},
},
{
type: 'PLAIN_TEXT',
value: ' and ',
},
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'user2',
},
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.have.members(['user1', 'user2']);
expect(result.mentions).to.have.lengthOf(2);
});
it('should deduplicate repeated mentions', () => {
const ast: Root = [
{
type: 'PARAGRAPH',
value: [
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'admin',
},
},
{
type: 'PLAIN_TEXT',
value: ' hello ',
},
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'admin',
},
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.deep.equal(['admin']);
});
it('should extract mentions from nested structures like blockquotes', () => {
const ast: Root = [
{
type: 'QUOTE',
value: [
{
type: 'PARAGRAPH',
value: [
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'quoted.user',
},
},
],
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.deep.equal(['quoted.user']);
});
it('should extract mentions from bold text', () => {
const ast: Root = [
{
type: 'PARAGRAPH',
value: [
{
type: 'BOLD',
value: [
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'bold.user',
},
},
],
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.deep.equal(['bold.user']);
});
it('should extract mentions from italic text', () => {
const ast: Root = [
{
type: 'PARAGRAPH',
value: [
{
type: 'ITALIC',
value: [
{
type: 'MENTION_CHANNEL',
value: {
type: 'PLAIN_TEXT',
value: 'italic-channel',
},
},
],
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.channels).to.deep.equal(['italic-channel']);
});
it('should handle special mentions like all and here', () => {
const ast: Root = [
{
type: 'PARAGRAPH',
value: [
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'all',
},
},
{
type: 'PLAIN_TEXT',
value: ' and ',
},
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'here',
},
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.have.members(['all', 'here']);
});
it('should handle empty AST', () => {
const ast: Root = [];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.be.an('array').that.is.empty;
expect(result.channels).to.be.an('array').that.is.empty;
});
it('should extract mentions from list items', () => {
const ast: Root = [
{
type: 'UNORDERED_LIST',
value: [
{
type: 'LIST_ITEM',
value: [
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'list.user',
},
},
],
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.deep.equal(['list.user']);
});
it('should extract mentions from tasks', () => {
const ast: Root = [
{
type: 'TASKS',
value: [
{
type: 'TASK',
status: false,
value: [
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'task.assignee',
},
},
],
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.deep.equal(['task.assignee']);
});
it('should handle BigEmoji AST structure', () => {
const ast: Root = [
{
type: 'BIG_EMOJI',
value: [
{
type: 'EMOJI',
value: {
type: 'PLAIN_TEXT',
value: 'smile',
},
shortCode: ':smile:',
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.be.an('array').that.is.empty;
expect(result.channels).to.be.an('array').that.is.empty;
});
it('should extract mentions from spoiler blocks', () => {
const ast: Root = [
{
type: 'SPOILER_BLOCK',
value: [
{
type: 'PARAGRAPH',
value: [
{
type: 'MENTION_USER',
value: {
type: 'PLAIN_TEXT',
value: 'hidden.user',
},
},
],
},
],
},
];
const result = extractMentionsFromMessageAST(ast);
expect(result.mentions).to.deep.equal(['hidden.user']);
});
});

View File

@@ -0,0 +1,18 @@
import { expect } from 'chai';
const messages = {
'Sample Message': 14,
'Sample 1 ⛳': 10,
'Sample 2 ❤': 10,
'Sample 3 ⛳❤⛳❤': 13,
};
describe('Message Properties', () => {
describe('Check Message Length', () => {
Object.keys(messages).forEach((objectKey) => {
it('should treat emojis as single characters', () => {
expect(objectKey.length).to.be.equal(messages[objectKey]);
});
});
});
});

View File

@@ -0,0 +1,90 @@
import type { MessageTypesValues, IRoom, IUser } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { getHiddenSystemMessages } from '../../../../../server/lib/messaging/getHiddenSystemMessages';
describe('getHiddenSystemMessages', () => {
it('should return room.sysMes if it is an array', async () => {
const room: IRoom = {
_id: 'roomId',
sysMes: ['mute_unmute', 'room_changed_description'] as MessageTypesValues[],
t: 'c',
msgs: 0,
u: {} as IUser,
usersCount: 0,
_updatedAt: new Date(),
};
const result = getHiddenSystemMessages(room, []);
expect(result).to.deep.equal(room.sysMes);
});
it('should return cached hidden system messages if room.sysMes is not an array', async () => {
const cachedHiddenSystemMessage: MessageTypesValues[] = ['mute_unmute', 'room_changed_description'];
const room: IRoom = {
_id: 'roomId',
t: 'c',
msgs: 0,
u: {} as IUser,
usersCount: 0,
_updatedAt: new Date(),
};
const result = getHiddenSystemMessages(room, cachedHiddenSystemMessage);
expect(result).to.deep.equal(['user-muted', 'user-unmuted', 'room_changed_description']);
});
it('should return an empty array if both room.sysMes and cached hidden system messages are undefined', async () => {
const room: IRoom = {
_id: 'roomId',
t: 'c',
msgs: 0,
u: {} as IUser,
usersCount: 0,
_updatedAt: new Date(),
};
const result = getHiddenSystemMessages(room, []);
expect(result).to.deep.equal([]);
});
it('should return cached hidden system messages if room.sysMes is null', async () => {
const cachedHiddenSystemMessage: MessageTypesValues[] = ['subscription-role-added', 'room_changed_announcement'];
const room: IRoom = {
_id: 'roomId',
sysMes: undefined,
t: 'c',
msgs: 0,
u: {} as IUser,
usersCount: 0,
_updatedAt: new Date(),
};
const result = getHiddenSystemMessages(room, cachedHiddenSystemMessage);
expect(result).to.deep.equal(cachedHiddenSystemMessage);
});
it('should return cached hidden system messages if room.sysMes array and hidden system message is available', async () => {
const cachedHiddenSystemMessage: MessageTypesValues[] = ['room_changed_announcement', 'room-archived'];
const room: IRoom = {
_id: 'roomId',
sysMes: ['mute_unmute', 'room_changed_description'] as MessageTypesValues[],
t: 'c',
msgs: 0,
u: {} as IUser,
usersCount: 0,
_updatedAt: new Date(),
};
const result = getHiddenSystemMessages(room, cachedHiddenSystemMessage);
expect(result).to.deep.equal(['mute_unmute', 'room_changed_description']);
});
});

View File

@@ -0,0 +1,287 @@
import { expect } from 'chai';
import { MentionsServer } from '../../../../../../server/lib/messaging/mentions/Mentions';
let mention;
beforeEach(() => {
mention = new MentionsServer({
pattern: () => '[0-9a-zA-Z-_.]+',
messageMaxAll: () => 4, // || RocketChat.settings.get('Message_MaxAll')
getUsers: async (usernames) =>
[
{
_id: 1,
username: 'rocket.cat',
},
{
_id: 2,
username: 'jon',
},
].filter((user) => usernames.includes(user.username)), // Meteor.users.find({ username: {$in: _.unique(usernames)}}, { fields: {_id: true, username: true }}).fetch();
getChannels(channels) {
return [
{
_id: 1,
name: 'general',
},
].filter((channel) => channels.includes(channel.name));
// return RocketChat.models.Rooms.find({ name: {$in: _.unique(channels)}, t: 'c' }, { fields: {_id: 1, name: 1 }}).fetch();
},
getUser: (userId) => ({ _id: userId, language: 'en' }),
getTotalChannelMembers: (/* rid*/) => 2,
});
});
describe('Mention Server', () => {
describe('getUsersByMentions', () => {
describe('for @all but the number of users is greater than messageMaxAll', () => {
beforeEach(() => {
mention.getTotalChannelMembers = () => 5;
});
it('should return nothing', async () => {
const message = {
msg: '@all',
};
const expected = [];
const result = await mention.getUsersByMentions(message);
expect(expected).to.be.deep.equal(result);
});
});
describe('for one user', () => {
beforeEach(() => {
mention.getChannel = () => ({
usernames: [
{
_id: 1,
username: 'rocket.cat',
},
{
_id: 2,
username: 'jon',
},
],
});
// Meteor.users.find({ username: {$in: _.unique(usernames)}}, { fields: {_id: true, username: true }}).fetch();
});
it('should return "all"', async () => {
const message = {
msg: '@all',
};
const expected = [
{
_id: 'all',
username: 'all',
},
];
const result = await mention.getUsersByMentions(message);
expect(expected).to.be.deep.equal(result);
});
it('should return "here"', async () => {
const message = {
msg: '@here',
};
const expected = [
{
_id: 'here',
username: 'here',
},
];
const result = await mention.getUsersByMentions(message);
expect(expected).to.be.deep.equal(result);
});
it('should return "rocket.cat"', async () => {
const message = {
msg: '@rocket.cat',
};
const expected = [
{
_id: 1,
username: 'rocket.cat',
},
];
const result = await mention.getUsersByMentions(message);
expect(expected).to.be.deep.equal(result);
});
});
describe('for two user', () => {
it('should return "all and here"', async () => {
const message = {
msg: '@all @here',
};
const expected = [
{
_id: 'all',
username: 'all',
},
{
_id: 'here',
username: 'here',
},
];
const result = await mention.getUsersByMentions(message);
expect(expected).to.be.deep.equal(result);
});
it('should return "here and rocket.cat"', async () => {
const message = {
msg: '@here @rocket.cat',
};
const expected = [
{
_id: 'here',
username: 'here',
},
{
_id: 1,
username: 'rocket.cat',
},
];
const result = await mention.getUsersByMentions(message);
expect(expected).to.be.deep.equal(result);
});
it('should return "here, rocket.cat, jon"', async () => {
const message = {
msg: '@here @rocket.cat @jon',
};
const expected = [
{
_id: 'here',
username: 'here',
},
{
_id: 1,
username: 'rocket.cat',
},
{
_id: 2,
username: 'jon',
},
];
const result = await mention.getUsersByMentions(message);
expect(expected).to.be.deep.equal(result);
});
});
describe('for an unknow user', () => {
it('should return "nothing"', async () => {
const message = {
msg: '@unknow',
};
const expected = [];
const result = await mention.getUsersByMentions(message);
expect(expected).to.be.deep.equal(result);
});
});
});
describe('getChannelbyMentions', () => {
it('should return the channel "general"', async () => {
const message = {
msg: '#general',
};
const expected = [
{
_id: 1,
name: 'general',
},
];
const result = await mention.getChannelbyMentions(message);
expect(result).to.be.deep.equal(expected);
});
it('should return nothing"', async () => {
const message = {
msg: '#unknow',
};
const expected = [];
const result = await mention.getChannelbyMentions(message);
expect(result).to.be.deep.equal(expected);
});
});
describe('execute', () => {
it('should return the channel "general"', async () => {
const message = {
msg: '#general',
};
const expected = [
{
_id: 1,
name: 'general',
},
];
const result = await mention.getChannelbyMentions(message);
expect(result).to.be.deep.equal(expected);
});
it('should return nothing"', async () => {
const message = {
msg: '#unknow',
};
const expected = {
msg: '#unknow',
mentions: [],
channels: [],
};
const result = await mention.execute(message);
expect(result).to.be.deep.equal(expected);
});
});
describe('getUserMentions', () => {
describe('for message with only an md link', () => {
const result = [];
[
'[@rocket.cat](https://rocket.chat)',
'[@rocket.cat](https://rocket.chat) hello',
'[@rocket.cat](https://rocket.chat) hello how are you?',
'[test](https://rocket.chat)',
].forEach((text) => {
it(`should return "${JSON.stringify(result)}" from "${text}"`, () => {
expect(result).to.be.deep.equal(mention.getUserMentions(text));
});
});
});
describe('for message with md link and text', () => {
const result = ['@sauron'];
[
'@sauron please work on [user@password](https://rocket.chat)',
'@sauron hello [user@password](https://rocket.chat) hello',
'[user@password](https://rocket.chat) hello @sauron',
'@sauron please work on [user@password](https://rocket.chat) hello',
].forEach((text) => {
it(`should return "${JSON.stringify(result)}" from "${text}"`, () => {
expect(result).to.be.deep.equal(mention.getUserMentions(text));
});
});
});
});
describe('getChannelMentions', () => {
describe('for message with md link', () => {
const result = [];
[
'[#general](https://rocket.chat)',
'[#general](https://rocket.chat) hello',
'[#general](https://rocket.chat) hello how are you?',
'[test #general #other](https://rocket.chat)',
].forEach((text) => {
it(`should return "${JSON.stringify(result)}" from "${text}"`, () => {
expect(result).to.be.deep.equal(mention.getChannelMentions(text));
});
});
});
describe('for message with md link and text', () => {
const result = ['#somechannel'];
[
'#somechannel please [user#password](https://rocket.chat)',
'#somechannel hello [user#password](https://rocket.chat) hello',
'[user#password](https://rocket.chat) hello #somechannel',
'#somechannel join [#general on #other](https://rocket.chat)',
].forEach((text) => {
it(`should return "${JSON.stringify(result)}" from "${text}"`, () => {
expect(result).to.be.deep.equal(mention.getChannelMentions(text));
});
});
});
});
});

View File

@@ -0,0 +1,430 @@
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import p from 'proxyquire';
import sinon from 'sinon';
const meteorMethodsMock = sinon.stub();
const emojiList: Record<string, any> = {};
const modelsMock = {
EmojiCustom: {
countByNameOrAlias: sinon.stub(),
},
Users: {
findOneById: sinon.stub(),
},
Messages: {
findOneById: sinon.stub(),
setReactions: sinon.stub(),
unsetReactions: sinon.stub(),
},
Rooms: {
findOneById: sinon.stub(),
unsetReactionsInLastMessage: sinon.stub(),
setReactionsInLastMessage: sinon.stub(),
},
};
const canAccessRoomAsyncMock = sinon.stub();
const isTheLastMessageMock = sinon.stub();
const notifyOnMessageChangeMock = sinon.stub();
const hasPermissionAsyncMock = sinon.stub();
const i18nMock = { t: sinon.stub() };
const callbacksRunMock = sinon.stub();
const meteorErrorMock = class extends Error {
constructor(message: string) {
super(message);
}
};
const { removeUserReaction, executeSetReaction, setReaction } = p
.noCallThru()
.load('../../../../../../server/lib/messaging/reactions/setReaction.ts', {
'@rocket.chat/models': modelsMock,
'@rocket.chat/core-services': { Message: { beforeReacted: sinon.stub() } },
'meteor/meteor': { Meteor: { methods: meteorMethodsMock, Error: meteorErrorMock } },
'../../callbacks': { callbacks: { run: callbacksRunMock } },
'../../i18n': { i18n: i18nMock },
'../../authorization': { canAccessRoomAsync: canAccessRoomAsyncMock },
'../../authorization/hasPermission': { hasPermissionAsync: hasPermissionAsyncMock },
'../emoji': { emoji: { list: emojiList } },
'../../messages/isTheLastMessage': { isTheLastMessage: isTheLastMessageMock },
'../../notifyListener': {
notifyOnMessageChange: notifyOnMessageChangeMock,
},
});
describe('Reactions', () => {
describe('removeUserReaction', () => {
it('should return the message unmodified when no reactions exist', () => {
const message = {};
const result = removeUserReaction(message as any, 'test', 'test');
expect(result).to.equal(message);
});
it('should remove the reaction from a message', () => {
const message = {
reactions: {
test: {
usernames: ['test', 'test2'],
},
},
};
const result = removeUserReaction(message as any, 'test', 'test');
expect(result.reactions.test.usernames).to.not.include('test');
expect(result.reactions.test.usernames).to.include('test2');
});
it('should remove the reaction from a message when the user is the last one on the array', () => {
const message = {
reactions: {
test: {
usernames: ['test'],
},
},
};
const result = removeUserReaction(message as any, 'test', 'test');
expect(result.reactions.test).to.be.undefined;
});
it('should remove username only from the reaction thats passed in', () => {
const message = {
reactions: {
test: {
usernames: ['test', 'test2'],
},
other: {
usernames: ['test', 'test2'],
},
},
};
const result = removeUserReaction(message as any, 'test', 'test');
expect(result.reactions.test.usernames).to.not.include('test');
expect(result.reactions.test.usernames).to.include('test2');
expect(result.reactions.other.usernames).to.include('test');
expect(result.reactions.other.usernames).to.include('test2');
});
it('should do nothing if username is not in the reaction', () => {
const message = {
reactions: {
test: {
usernames: ['test', 'test2'],
},
},
};
const result = removeUserReaction(message as any, 'test', 'test3');
expect(result.reactions.test.usernames).to.not.include('test3');
expect(result.reactions.test.usernames).to.include('test');
expect(result.reactions.test.usernames).to.include('test2');
});
});
describe('executeSetReaction', () => {
beforeEach(() => {
modelsMock.EmojiCustom.countByNameOrAlias.reset();
});
it('should throw an error if reaction is not on emoji list', async () => {
modelsMock.EmojiCustom.countByNameOrAlias.resolves(0);
await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed');
});
it('should fail if user does not exist', async () => {
modelsMock.EmojiCustom.countByNameOrAlias.resolves(1);
await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-invalid-user');
});
it('should fail if message does not exist', async () => {
modelsMock.EmojiCustom.countByNameOrAlias.resolves(1);
modelsMock.Users.findOneById.resolves({ username: 'test' });
await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed');
});
it('should return nothing if user already reacted and its trying to react again', async () => {
modelsMock.EmojiCustom.countByNameOrAlias.resolves(1);
modelsMock.Users.findOneById.resolves({ username: 'test' });
modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } });
expect(await executeSetReaction('test', 'test', 'test', true)).to.be.undefined;
});
it('should return nothing if user hasnt reacted and its trying to unreact', async () => {
modelsMock.EmojiCustom.countByNameOrAlias.resolves(1);
modelsMock.Users.findOneById.resolves({ username: 'test' });
modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['testxxxx'] } } });
expect(await executeSetReaction('test', 'test', 'test', false)).to.be.undefined;
});
it('should fail if room does not exist', async () => {
modelsMock.EmojiCustom.countByNameOrAlias.resolves(1);
modelsMock.Users.findOneById.resolves({ username: 'test' });
modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } });
modelsMock.Rooms.findOneById.resolves(undefined);
await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed');
});
it('should fail if user doesnt have acccess to the room', async () => {
modelsMock.EmojiCustom.countByNameOrAlias.resolves(1);
modelsMock.Users.findOneById.resolves({ username: 'test' });
modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } });
modelsMock.Rooms.findOneById.resolves({ t: 'd' });
canAccessRoomAsyncMock.resolves(false);
await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('not-authorized');
});
it('should call setReaction with correct params', async () => {
modelsMock.EmojiCustom.countByNameOrAlias.resolves(1);
modelsMock.Users.findOneById.resolves({ username: 'test' });
modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } });
modelsMock.Rooms.findOneById.resolves({ t: 'c' });
canAccessRoomAsyncMock.resolves(true);
const res = await executeSetReaction('test', 'test', 'test');
expect(res).to.be.undefined;
});
it('should use the message from param when the type is not an string', async () => {
modelsMock.EmojiCustom.countByNameOrAlias.resolves(1);
modelsMock.Users.findOneById.resolves({ username: 'test' });
modelsMock.Rooms.findOneById.resolves({ t: 'c' });
canAccessRoomAsyncMock.resolves(true);
await executeSetReaction('test', 'test', { reactions: { ':test:': { usernames: ['test'] } } });
expect(modelsMock.Messages.findOneById.calledOnce).to.be.false;
});
});
describe('setReaction', () => {
beforeEach(() => {
canAccessRoomAsyncMock.reset();
hasPermissionAsyncMock.reset();
isTheLastMessageMock.reset();
modelsMock.Messages.setReactions.reset();
modelsMock.Rooms.setReactionsInLastMessage.reset();
modelsMock.Rooms.unsetReactionsInLastMessage.reset();
modelsMock.Messages.unsetReactions.reset();
callbacksRunMock.reset();
});
it('should throw an error if user is muted from the room', async () => {
const room = {
muted: ['test'],
};
const user = {
username: 'test',
};
const message = {
_id: 'test',
};
await expect(setReaction(room, user, message, ':test:')).to.be.rejectedWith('error-not-allowed');
});
it('should throw an error if room is readonly and cannot be reacted when readonly and user trying doesnt have permissions and user is not unmuted from room', async () => {
const room = {
ro: true,
reactWhenReadOnly: false,
};
const user = {
username: 'test',
};
const message = {
_id: 'test',
};
canAccessRoomAsyncMock.resolves(false);
await expect(setReaction(room, user, message, ':test:')).to.be.rejectedWith("You can't send messages because the room is readonly.");
});
it('should remove the user reaction if userAlreadyReacted is true and call unsetReaction if reaction is the last one on message', async () => {
const room = {
_id: 'test',
};
const user = {
username: 'test',
};
const message = {
_id: 'test',
reactions: {
':test:': {
usernames: ['test'],
},
},
};
const reaction = ':test:';
await setReaction(room, user, message, reaction, true);
expect(modelsMock.Messages.unsetReactions.calledWith(message._id)).to.be.true;
});
it('should call Rooms.unsetReactionsInLastMessage when userAlreadyReacted is true and reaction is the last one on message', async () => {
const room = {
_id: 'test',
lastMessage: 'test',
};
const user = {
username: 'test',
};
const message = {
_id: 'test',
reactions: {
':test:': {
usernames: ['test'],
},
},
};
const reaction = ':test:';
isTheLastMessageMock.resolves(true);
await setReaction(room, user, message, reaction, true);
expect(modelsMock.Messages.unsetReactions.calledWith(message._id)).to.be.true;
expect(modelsMock.Rooms.unsetReactionsInLastMessage.calledWith(room._id)).to.be.true;
});
it('should update the reactions object when userAlreadyReacted is true and there is more reactions on message', async () => {
const room = {
_id: 'test',
};
const user = {
username: 'test',
};
const message = {
_id: 'test',
reactions: {
':test:': {
usernames: ['test'],
},
':test2:': {
usernames: ['test'],
},
},
};
const reaction = ':test:';
await setReaction(room, user, message, reaction, true);
expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be.true;
});
it('should call Rooms.setReactionsInLastMessage when userAlreadyReacted is true and reaction is not the last one on message', async () => {
const room = {
_id: 'test',
lastMessage: 'test',
};
const user = {
username: 'test',
};
const message = {
_id: 'test',
reactions: {
':test:': {
usernames: ['test'],
},
':test2:': {
usernames: ['test'],
},
},
};
const reaction = ':test:';
isTheLastMessageMock.resolves(true);
await setReaction(room, user, message, reaction, true);
expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be.true;
expect(modelsMock.Rooms.setReactionsInLastMessage.calledWith(room._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be
.true;
});
it('should call afterUnsetReaction callback when userAlreadyReacted is true', async () => {
const room = {
_id: 'test',
};
const user = {
username: 'test',
};
const message = {
_id: 'test',
reactions: {
':test:': {
usernames: ['test'],
},
},
};
const reaction = ':test:';
await setReaction(room, user, message, reaction, true);
expect(
callbacksRunMock.calledWith(
'afterUnsetReaction',
sinon.match({ _id: 'test' }),
sinon.match({ user, reaction, shouldReact: false, oldMessage: message }),
),
).to.be.true;
});
it('should set reactions when userAlreadyReacted is false', async () => {
const room = {
_id: 'test',
};
const user = {
username: 'test',
};
const message = {
_id: 'test',
};
const reaction = ':test:';
await setReaction(room, user, message, reaction, false);
expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be.true;
});
it('should properly add username to the list of reactions when userAlreadyReacted is false', async () => {
const room = {
_id: 'test',
};
const user = {
username: 'test2',
};
const message = {
_id: 'test',
reactions: {
':test:': {
usernames: ['test'],
},
},
};
const reaction = ':test:';
await setReaction(room, user, message, reaction, false);
expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test', 'test2'] } }))).to.be
.true;
});
it('should call Rooms.setReactionInLastMessage when userAlreadyReacted is false', async () => {
const room = {
_id: 'x5',
lastMessage: 'test',
};
const user = {
username: 'test',
};
const message = {
_id: 'test',
};
const reaction = ':test:';
isTheLastMessageMock.resolves(true);
await setReaction(room, user, message, reaction, false);
expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be.true;
expect(modelsMock.Rooms.setReactionsInLastMessage.calledWith(room._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be
.true;
});
it('should call afterSetReaction callback when userAlreadyReacted is false', async () => {
const room = {
_id: 'test',
};
const user = {
username: 'test',
};
const message = {
_id: 'test',
};
const reaction = ':test:';
await setReaction(room, user, message, reaction, false);
expect(
callbacksRunMock.calledWith('afterSetReaction', sinon.match({ _id: 'test' }), sinon.match({ user, reaction, shouldReact: true })),
).to.be.true;
});
it('should return undefined on a successful reaction', async () => {
const room = {
_id: 'test',
};
const user = {
username: 'test',
};
const message = {
_id: 'test',
};
const reaction = ':test:';
expect(await setReaction(room, user, message, reaction, false)).to.be.undefined;
});
});
});

View File

@@ -0,0 +1,86 @@
import { expect } from 'chai';
import { validateCustomMessageFields } from '../../../../../server/lib/messaging/validateCustomMessageFields';
describe('validateCustomMessageFields', () => {
describe('When not enabled', () => {
it('should not allow to pass custom fields', () => {
const customFields = {
test: 'test',
};
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: false, messageCustomFields: '' })).to.throw(
'Custom fields not enabled',
);
});
});
describe('When enabled', () => {
it('should not allow to pass invalid custom fields config', () => {
const customFields = {
test: 'test',
};
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields: '' })).to.throw(
'Unexpected end of JSON input',
);
});
it('should not allow to pass a property not present in config', () => {
const customFields = {
test: 'test',
};
const messageCustomFields = JSON.stringify({
properties: {
priority: {
type: 'string',
},
},
additionalProperties: true,
});
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.throw(
'Invalid custom fields',
);
});
it('should not allow to pass an invalid custom field value', () => {
const customFields = {
test: 123,
};
const messageCustomFields = JSON.stringify({
properties: {
priority: {
type: 'string',
},
},
additionalProperties: true,
});
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.throw(
'Invalid custom fields',
);
});
it('should not allow to pass anything different from an object', () => {
const customFields = [1, 2];
const messageCustomFields = JSON.stringify({
type: 'array',
items: [{ type: 'integer' }, { type: 'integer' }],
});
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.throw(
'Invalid custom fields config',
);
});
it('should allow to pass a valid custom fields config', () => {
const customFields = {
test: 'test',
};
const messageCustomFields = JSON.stringify({
properties: {
test: {
type: 'string',
},
},
});
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.not.throw();
});
});
});

View File

@@ -0,0 +1,67 @@
import { expect } from 'chai';
import { replaceVariables } from '../../../../../../server/lib/notifications/email/replaceVariables';
describe('Mailer-API', () => {
describe('replaceVariables', () => {
const i18n: {
[key: string]: string;
} = {
key: 'value',
};
describe('single key', function functionName() {
it(`should be equal to test ${i18n.key}`, () => {
expect(`test ${i18n.key}`).to.be.equal(replaceVariables('test {key}', (_match, key) => i18n[key]));
});
});
describe('multiple keys', function functionName() {
it(`should be equal to test ${i18n.key} and ${i18n.key}`, () => {
expect(`test ${i18n.key} and ${i18n.key}`).to.be.equal(replaceVariables('test {key} and {key}', (_match, key) => i18n[key]));
});
});
describe('key with a trailing space', function functionName() {
it(`should be equal to test ${i18n.key}`, () => {
expect(`test ${i18n.key}`).to.be.equal(replaceVariables('test {key }', (_match, key) => i18n[key]));
});
});
describe('key with a leading space', function functionName() {
it(`should be equal to test ${i18n.key}`, () => {
expect(`test ${i18n.key}`).to.be.equal(replaceVariables('test { key}', (_match, key) => i18n[key]));
});
});
describe('key with leading and trailing spaces', function functionName() {
it(`should be equal to test ${i18n.key}`, () => {
expect(`test ${i18n.key}`).to.be.equal(replaceVariables('test { key }', (_match, key) => i18n[key]));
});
});
describe('key with multiple words', function functionName() {
it(`should be equal to test ${i18n.key}`, () => {
expect(`test ${i18n.key}`).to.be.equal(replaceVariables('test {key ignore}', (_match, key) => i18n[key]));
});
});
describe('key with multiple opening brackets', function functionName() {
it(`should be equal to test {${i18n.key}`, () => {
expect(`test {${i18n.key}`).to.be.equal(replaceVariables('test {{key}', (_match, key) => i18n[key]));
});
});
describe('key with multiple closing brackets', function functionName() {
it(`should be equal to test ${i18n.key}}`, () => {
expect(`test ${i18n.key}}`).to.be.equal(replaceVariables('test {key}}', (_match, key) => i18n[key]));
});
});
describe('key with multiple opening and closing brackets', function functionName() {
it(`should be equal to test {${i18n.key}}`, () => {
expect(`test {${i18n.key}}`).to.be.equal(replaceVariables('test {{key}}', (_match, key) => i18n[key]));
});
});
});
});

View File

@@ -0,0 +1,95 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { expect } from 'chai';
import { describe, it } from 'mocha';
import proxyquire from 'proxyquire';
const mocks = {
'@rocket.chat/tools': {
escapeHTML: (str: string) => str,
},
'meteor/meteor': {
Meteor: {
startup: () => {},
},
},
'../../callbacks': {
callbacks: {
run: () => {},
},
},
'../../i18n': {
i18n: {
t: (trans: string) => trans,
},
},
'../../rooms/roomCoordinator': {
roomCoordinator: {
getRoomDirectives: () => ({
isGroupChat: () => true,
}),
getRoomName: () => '',
},
},
'../email/api': {
getTemplate: () => {},
send: () => {},
replace: () => {},
},
'../../../settings': {
settings: {
get: () => true,
watch: () => {},
},
},
'../../metrics': {
metrics: {},
},
'../../utils/getURL': {
getURL: () => {},
},
};
const message = {
u: {
name: 'rocket.cat',
username: 'rocket.cat',
},
};
const room = {
fname: 'room',
name: 'room',
t: 'p',
};
describe('getEmailContent', () => {
it('should return preview string for encrypted message', async () => {
const { getEmailContent } = proxyquire.noCallThru().load('../../../../../../server/lib/notifications/message/email.js', mocks);
const result = await getEmailContent({
message: { ...message, t: 'e2e' },
user: undefined,
room,
});
expect(result).to.be.equal('Encrypted_message_preview_unavailable');
});
it('should return header for encrypted message if Email_notification_show_message is turned off', async () => {
const { getEmailContent } = proxyquire.noCallThru().load('../../../../../../server/lib/notifications/message/email.js', {
...mocks,
'../../../settings': {
settings: {
get: () => false,
watch: () => {},
},
},
});
const result = await getEmailContent({
message: { ...message, t: 'e2e' },
user: undefined,
room,
});
expect(result).to.be.equal('User_sent_a_message_on_channel');
});
});

View File

@@ -0,0 +1,90 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { messageContainsHighlight } from '../../../../../../server/lib/notifications/message/messageContainsHighlight';
describe('messageContainsHighlight', () => {
it('should return false for no highlights', async () => {
const message = {
msg: 'regular message',
};
expect(messageContainsHighlight(message, [])).to.be.false;
});
it('should return true when find a highlight in the beggining of the message', async () => {
const message = {
msg: 'highlighted regular message',
};
expect(messageContainsHighlight(message, ['highlighted'])).to.be.true;
});
it('should return true when find a highlight in the end of the message', async () => {
const message = {
msg: 'highlighted regular message',
};
expect(messageContainsHighlight(message, ['message'])).to.be.true;
});
it('should return false if the highlight is just part of the word', async () => {
const message = {
msg: 'highlighted regular message',
};
expect(messageContainsHighlight(message, ['light'])).to.be.false;
});
it('should return true if find one of the multiple highlights', async () => {
const message = {
msg: 'highlighted regular message',
};
expect(messageContainsHighlight(message, ['high', 'ssage', 'regular', 'light'])).to.be.true;
});
it('should return true if highlight case not match', async () => {
const message = {
msg: 'highlighted regular message',
};
expect(messageContainsHighlight(message, ['ReGuLaR'])).to.be.true;
});
it('should return false if the highlight word is an emoji', async () => {
const message = {
msg: 'highlighted :thumbsup: message',
};
expect(messageContainsHighlight(message, ['thumbsup'])).to.be.false;
});
it('should return true for a highlight word beggining with :', async () => {
const message = {
msg: 'highlighted :thumbsup message',
};
expect(messageContainsHighlight(message, ['thumbsup'])).to.be.true;
});
it('should return true for a highlight word ending with :', async () => {
const message = {
msg: 'highlighted thumbsup: message',
};
expect(messageContainsHighlight(message, ['thumbsup'])).to.be.true;
});
it('should return true when find a cyrillic highlight in the end of the message', async () => {
const message = {
msg: 'highlighted regular Привет',
};
expect(messageContainsHighlight(message, ['Привет'])).to.be.true;
});
it('should return true when find a cyrillic highlight in the beginning of the message', async () => {
const message = {
msg: 'Привет highlighted regular',
};
expect(messageContainsHighlight(message, ['Привет'])).to.be.true;
});
it('should return true when find a cyrillic highlight in the middle of the message', async () => {
const message = {
msg: 'highlighted Привет regular',
};
expect(messageContainsHighlight(message, ['Привет'])).to.be.true;
});
});

View File

@@ -0,0 +1,112 @@
import type { IPushNotificationConfig } from '@rocket.chat/core-typings/src/IPushNotificationConfig';
import { pick, truncateString } from '@rocket.chat/tools';
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const loggerStub = { debug: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), info: sinon.stub(), log: sinon.stub() };
const settingsStub = { get: sinon.stub().returns('') };
const { Push } = proxyquire.noCallThru().load('../../../../../../server/lib/notifications/push/push', {
'./logger': { logger: loggerStub },
'../../../settings': { settings: settingsStub },
'@rocket.chat/tools': { pick, truncateString },
'meteor/check': {
check: sinon.stub(),
Match: {
Optional: () => sinon.stub(),
Integer: Number,
OneOf: () => sinon.stub(),
test: sinon.stub().returns(true),
},
},
'meteor/meteor': {
Meteor: {
absoluteUrl: sinon.stub().returns('http://localhost'),
},
},
});
describe('Push Notifications [PushClass]', () => {
afterEach(() => {
sinon.restore();
});
describe('send()', () => {
let sendNotificationStub: sinon.SinonStub;
beforeEach(() => {
sendNotificationStub = sinon.stub(Push, 'sendNotification').resolves({ apn: [], gcm: [] });
});
it('should call sendNotification with required fields', async () => {
const options: IPushNotificationConfig = {
from: 'test',
title: 'title',
text: 'body',
userId: 'user1',
apn: { category: 'MESSAGE' },
gcm: { style: 'inbox', image: 'url' },
};
await Push.send(options);
expect(sendNotificationStub.calledOnce).to.be.true;
const notification = sendNotificationStub.firstCall.args[0];
expect(notification.from).to.equal('test');
expect(notification.title).to.equal('title');
expect(notification.text).to.equal('body');
expect(notification.userId).to.equal('user1');
});
it('should truncate text if longer than 240 chars', async () => {
const longText = 'a'.repeat(300);
const options: IPushNotificationConfig = {
from: 'test',
title: 'title',
text: longText,
userId: 'user1',
apn: { category: 'MESSAGE' },
gcm: { style: 'inbox', image: 'url' },
};
await Push.send(options);
const notification = sendNotificationStub.firstCall.args[0];
expect(notification.text.length).to.equal(240);
});
it('should truncate title if longer than 65 chars', async () => {
const longTitle = 'a'.repeat(100);
const options: IPushNotificationConfig = {
from: 'test',
title: longTitle,
text: 'bpdu',
userId: 'user1',
apn: { category: 'MESSAGE' },
gcm: { style: 'inbox', image: 'url' },
};
await Push.send(options);
const notification = sendNotificationStub.firstCall.args[0];
expect(notification.title.length).to.equal(65);
});
it('should throw if userId is missing', async () => {
const options = {
from: 'test',
title: 'title',
text: 'body',
apn: { category: 'MESSAGE' },
gcm: { style: 'inbox', image: 'url' },
};
await expect(Push.send(options)).to.be.rejectedWith('No userId found');
expect(sendNotificationStub.called).to.be.false;
});
});
});

View File

@@ -0,0 +1,303 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
describe('Message Broadcast Tests', () => {
let getSettingValueByIdStub: sinon.SinonStub;
let usersFindOneStub: sinon.SinonStub;
let usersFindByUsernamesStub: sinon.SinonStub;
let messagesFindOneStub: sinon.SinonStub;
let broadcastStub: sinon.SinonStub;
let getMessageToBroadcast: any;
let notifyOnMessageChange: any;
let memStub: sinon.SinonStub;
const sampleMessage: IMessage = {
_id: '123',
rid: 'room1',
msg: 'Hello',
ts: new Date(),
u: { _id: 'user1', username: 'user1', name: 'Real User' },
mentions: [],
t: 'user-muted',
_updatedAt: new Date(),
};
const modelsStubs = () => ({
Messages: {
findOneById: messagesFindOneStub,
},
Users: {
findOne: usersFindOneStub,
findByUsernames: usersFindByUsernamesStub,
},
Settings: {
getValueById: getSettingValueByIdStub,
},
});
const coreStubs = () => ({
api: {
broadcast: broadcastStub,
},
});
beforeEach(() => {
getSettingValueByIdStub = sinon.stub();
usersFindOneStub = sinon.stub();
usersFindByUsernamesStub = sinon.stub();
messagesFindOneStub = sinon.stub();
broadcastStub = sinon.stub();
memStub = sinon.stub().callsFake((fn: any) => fn);
const proxyMock = proxyquire.noPreserveCache().load('../../../../server/lib/notifyListener', {
'@rocket.chat/models': modelsStubs(),
'@rocket.chat/core-services': coreStubs(),
'mem': memStub,
});
getMessageToBroadcast = proxyMock.getMessageToBroadcast;
notifyOnMessageChange = proxyMock.notifyOnMessageChange;
});
describe('getMessageToBroadcast', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = { ...process.env };
sinon.resetHistory();
});
afterEach(() => {
process.env = originalEnv;
});
const testCases = [
{
description: 'should return undefined if message is hidden',
message: { ...sampleMessage, _hidden: true },
hideSystemMessages: [],
useRealName: false,
expectedResult: undefined,
},
{
description: 'should return undefined if message is imported',
message: { ...sampleMessage, imported: true },
hideSystemMessages: [],
useRealName: false,
expectedResult: undefined,
},
{
description: 'should hide message if type is in hideSystemMessage settings',
message: sampleMessage,
hideSystemMessages: ['user-muted', 'mute_unmute'],
useRealName: false,
expectedResult: undefined,
},
{
description: 'should return the message with real name if useRealName is true',
message: sampleMessage,
hideSystemMessages: [],
useRealName: true,
expectedResult: { ...sampleMessage, u: { ...sampleMessage.u, name: 'Real User' } },
},
{
description: 'should return the message with reactions real names if useRealName is true',
message: {
...sampleMessage,
t: undefined,
reactions: {
':smile:': { usernames: ['user1', 'user2'] },
':heart:': { usernames: ['user1', 'user3'] },
},
},
hideSystemMessages: [],
useRealName: true,
expectedResult: {
...sampleMessage,
t: undefined,
u: { ...sampleMessage.u, name: 'Real User' },
reactions: {
':smile:': { usernames: ['user1', 'user2'], names: ['Real User', 'Name for user2'] },
':heart:': { usernames: ['user1', 'user3'], names: ['Real User', 'Name for user3'] },
},
},
},
{
description: 'should return the message with empty reactions without querying users if useRealName is true',
message: {
...sampleMessage,
t: undefined,
reactions: {},
},
hideSystemMessages: [],
useRealName: true,
expectedResult: {
...sampleMessage,
t: undefined,
u: { ...sampleMessage.u, name: 'Real User' },
reactions: {},
},
},
{
description: 'should return the message with mentions real name if useRealName is true',
message: {
...sampleMessage,
mentions: [
{ _id: 'mention1', username: 'mention1', name: 'Mention 1' },
{ _id: 'mention2', username: 'mention2', name: 'Mention 2' },
],
},
hideSystemMessages: [],
useRealName: true,
expectedResult: {
...sampleMessage,
u: { ...sampleMessage.u, name: 'Real User' },
mentions: [
{ _id: 'mention1', username: 'mention1', name: 'Mention 1' },
{ _id: 'mention2', username: 'mention2', name: 'Mention 2' },
],
},
},
{
description: 'should return the message if Hide_System_Messages is undefined',
message: sampleMessage,
hideSystemMessages: undefined,
useRealName: false,
expectedResult: sampleMessage,
},
{
description: 'should return undefined if the message type is muted and a mute_unmute is received',
message: { ...sampleMessage, t: 'mute_unmute' },
hideSystemMessages: ['user-muted', 'mute_unmute'],
useRealName: false,
expectedResult: undefined,
},
{
description: 'should return the message if no system messages are muted',
message: sampleMessage,
hideSystemMessages: [],
useRealName: false,
expectedResult: sampleMessage,
},
{
description: 'should hide message if type is room-archived',
message: { ...sampleMessage, t: 'room-archived' },
hideSystemMessages: ['room-archived'],
useRealName: false,
expectedResult: undefined,
},
{
description: 'should hide message if type is user-unmuted',
message: { ...sampleMessage, t: 'user-unmuted' },
hideSystemMessages: ['user-unmuted'],
useRealName: false,
expectedResult: undefined,
},
{
description: 'should hide message if type is subscription-role-added',
message: { ...sampleMessage, t: 'subscription-role-added' },
hideSystemMessages: ['subscription-role-added'],
useRealName: false,
expectedResult: undefined,
},
{
description: 'should hide message if type is message_pinned',
message: { ...sampleMessage, t: 'message_pinned' },
hideSystemMessages: ['message_pinned'],
useRealName: false,
expectedResult: undefined,
},
{
description: 'should hide message if type is new-owner',
message: { ...sampleMessage, t: 'new-owner' },
hideSystemMessages: ['new-owner'],
useRealName: false,
expectedResult: undefined,
},
];
testCases.forEach(({ description, message, hideSystemMessages, useRealName, expectedResult }) => {
it(description, async () => {
messagesFindOneStub.resolves(message);
getSettingValueByIdStub.withArgs('Hide_System_Messages').resolves(hideSystemMessages);
getSettingValueByIdStub.withArgs('UI_Use_Real_Name').resolves(useRealName);
if (useRealName) {
const realNames: (string | undefined)[] = [message.u.name];
if (message.mentions) {
message.mentions.forEach((mention) => realNames.push(mention.name));
}
realNames.forEach((user, index) => usersFindOneStub.onCall(index).resolves({ name: user }));
if (message.reactions) {
const allUsernames = [...new Set(Object.values(message.reactions).flatMap((r) => r.usernames))];
const users = allUsernames.map((username) => ({
username,
name: username === message.u.username ? message.u.name : `Name for ${username}`,
}));
usersFindByUsernamesStub.returns({ toArray: () => Promise.resolve(users) });
}
}
const result = await getMessageToBroadcast({ id: '123' });
expect(result).to.deep.equal(expectedResult);
if (useRealName && message.reactions && Object.keys(message.reactions).length) {
const deduplicated = [...new Set(Object.values(message.reactions).flatMap((r) => r.usernames))];
expect(usersFindByUsernamesStub.calledOnce).to.be.true;
expect(usersFindByUsernamesStub.calledWith(deduplicated, { projection: { username: 1, name: 1 } })).to.be.true;
}
if (useRealName && message.reactions && !Object.keys(message.reactions).length) {
expect(usersFindByUsernamesStub.called).to.be.false;
}
});
});
});
describe('notifyOnMessageChange', () => {
const setupProxyMock = () => {
const proxyMock = proxyquire.noCallThru().load('../../../../server/lib/notifyListener', {
'@rocket.chat/models': modelsStubs(),
'@rocket.chat/core-services': coreStubs(),
'mem': memStub,
});
notifyOnMessageChange = proxyMock.notifyOnMessageChange;
};
const testCases = [
{
description: 'should broadcast the message if there is data attributes',
expectBroadcast: true,
message: sampleMessage,
},
{
description: 'should not broadcast the message if there is no data attributes',
expectBroadcast: false,
message: null,
},
];
testCases.forEach(({ description, expectBroadcast, message }) => {
it(description, async () => {
setupProxyMock();
messagesFindOneStub.resolves(message);
getSettingValueByIdStub.resolves([]);
await notifyOnMessageChange({ id: '123', data: message });
if (expectBroadcast) {
expect(broadcastStub.calledOnce).to.be.true;
expect(broadcastStub.calledOnceWith('watch.messages', { message })).to.be.true;
} else {
expect(broadcastStub.called).to.be.false;
}
});
});
});
});

View File

@@ -0,0 +1,35 @@
import { expect } from 'chai';
import { describe, it, before, after } from 'mocha';
import { addServerUrlToIndex } from '../../../../../server/lib/omnichannel/Assets';
describe('addServerUrlToIndex', () => {
before(() => {
(global as any).__meteor_runtime_config__ = { ROOT_URL: 'http://localhost:3000' };
});
after(() => {
delete (global as any).__meteor_runtime_config__;
});
it('should do nothing if "file" has no <body> tag', () => {
const file = addServerUrlToIndex('file');
expect(file).to.equal('file');
});
it('should replace <body> with an script tag containing the SERVER_URL inside', () => {
const file = addServerUrlToIndex('<html><body></body></html>');
expect(file).to.equal("<html><body><script> SERVER_URL = 'http://localhost:3000'; </script></body></html>");
});
it('should remove any trailing / from the SERVER_URL', () => {
(global as any).__meteor_runtime_config__ = { ROOT_URL: 'http://localhost:3000/' };
const file = addServerUrlToIndex('<html><body></body></html>');
expect(file).to.equal("<html><body><script> SERVER_URL = 'http://localhost:3000'; </script></body></html>");
});
it('should use the value from the global variable always', () => {
const file = addServerUrlToIndex('<html><body></body></html>');
expect(file).to.equal("<html><body><script> SERVER_URL = 'http://localhost:3000'; </script></body></html>");
(global as any).__meteor_runtime_config__ = { ROOT_URL: 'http://kodiak.rocket.chat/' };
const file2 = addServerUrlToIndex('<html><body></body></html>');
expect(file2).to.equal("<html><body><script> SERVER_URL = 'http://kodiak.rocket.chat'; </script></body></html>");
});
});

View File

@@ -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;
});
});
});

View File

@@ -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;
});
});

View File

@@ -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);
});
});
});

View File

@@ -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',
},
});
});
});

View File

@@ -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;
});
});

View File

@@ -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;
});
});

View File

@@ -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',
});
});
});

View File

@@ -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<string, any> = {
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: '<div> <hr></div>',
}),
).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: '<div> <hr></div>',
}),
).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: '<div> <hr></div>',
}),
).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: '<div> <hr></div>',
}),
).to.be.true;
});
});

View File

@@ -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;
}
}
});
});

View File

@@ -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<string>;
};
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<Record<'Livechat_http_timeout' | 'Livechat_secret_token' | 'Livechat_webhookUrl', any>>;
}) {
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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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;
});
});

View File

@@ -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<IUser, '_id' | 'username' | 'federated' | 'federation'>;
nameOrId: string;
type?: RoomType;
tryDirectByUserIdOnly?: boolean;
joinChannel?: boolean;
errorOnEmpty?: boolean;
}) => Promise<IRoom | null>;
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' });
});
});
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,43 @@
import type { SAMLDocumentType, SAMLPOSTEnvelope, SAMLRedirectEnvelope } from '../../../../../server/lib/saml/definition/SAMLEnvelope';
export function makeLogoutEnvelope<T extends SAMLDocumentType>(
type: T,
xml: string,
signedContent?: string,
signature?: string,
): SAMLRedirectEnvelope<T> {
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<T extends SAMLDocumentType>(type: T, xml: string): SAMLPOSTEnvelope<T> {
return {
binding: 'HTTP-POST',
encodedDocument: xml,
decodedDocument: xml,
type,
relayState: 'relayState',
};
}
export function makeLoginResponseEnvelope(xml: string) {
return makeLoginEnvelope('SAMLResponse', xml);
}

View File

@@ -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') => `
<KeyDescriptor${use ? ` use="${use}"` : ''}>
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data><ds:X509Certificate>${cert}</ds:X509Certificate></ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>`;
const metadata = ({
keys = keyDescriptor(TEST_CERT),
slo = `<SingleLogoutService Binding="${REDIRECT}" Location="https://idp.test/slo"/>`,
sso = `<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso"/>`,
nameIdFormats = '',
} = {}) => `<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://idp.test/metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
${keys}
${nameIdFormats}
${slo}
${sso}
</IDPSSODescriptor>
</EntityDescriptor>`;
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: `<SingleSignOnService Binding="${POST}" Location="https://idp.test/sso-post"/>` }));
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 = `
<SingleSignOnService Binding="${REDIRECT}" Location="not a url"/>
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso-2"/>`;
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 = `<SingleSignOnService Binding="${REDIRECT}" Location="javascript:alert(1)"/>`;
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 = `<SingleLogoutService Binding="${POST}" Location="https://idp.test/slo-post"/>`;
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 = `
<SingleLogoutService Binding="${POST}" Location="https://idp.test/slo-post"/>
<SingleLogoutService Binding="${REDIRECT}" Location="https://idp.test/slo-redirect"/>`;
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 = `
<Extensions>
<SingleSignOnService Binding="${REDIRECT}" Location="https://attacker.test/sso"/>
<SingleLogoutService Binding="${REDIRECT}" Location="https://attacker.test/slo"/>
</Extensions>
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/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: '<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress</NameIDFormat>' }),
);
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 = `
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress</NameIDFormat>
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>`;
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 = `<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://mixed.test">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol">
<SingleSignOnService Binding="urn:mace:shibboleth:1.0:profiles:AuthnRequest" Location="https://mixed.test/saml1/sso"/>
</IDPSSODescriptor>
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol urn:oasis:names:tc:SAML:2.0:protocol">
<SingleSignOnService Binding="${REDIRECT}" Location="https://mixed.test/saml2/sso"/>
</IDPSSODescriptor>
</EntityDescriptor>`;
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 = `<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://saml1.test">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol">
<SingleSignOnService Binding="${REDIRECT}" Location="https://saml1.test/sso"/>
</IDPSSODescriptor>
</EntityDescriptor>`;
expect(() => parseIdpMetadata(saml1Only)).to.throw(InvalidIdpMetadataError);
});
it('rejects SP-only metadata (no IDPSSODescriptor)', () => {
const spOnly = `<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://sp.test">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
</EntityDescriptor>`;
expect(() => parseIdpMetadata(spOnly)).to.throw(InvalidIdpMetadataError);
});
it('rejects a document whose root is not EntityDescriptor', () => {
expect(() => parseIdpMetadata('<html><body>nope</body></html>')).to.throw(InvalidIdpMetadataError);
});
it('rejects a federation aggregate wrapping EntityDescriptors', () => {
const aggregate = `<?xml version="1.0"?>
<EntitiesDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<EntityDescriptor entityID="https://idp.test/metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso"/>
</IDPSSODescriptor>
</EntityDescriptor>
</EntitiesDescriptor>`;
expect(() => parseIdpMetadata(aggregate)).to.throw(InvalidIdpMetadataError);
});
it('rejects an otherwise-valid document with an unclosed inner element', () => {
// xmldom auto-closes the unclosed <ds:KeyInfo> into a structurally-complete document (every guard
// passes), so this only throws because the parser treats the `warning` it emits as fatal.
const unclosedInner = `<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://idp.test/metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data><ds:X509Certificate>${TEST_CERT}</ds:X509Certificate></ds:X509Data>
</KeyDescriptor>
<SingleLogoutService Binding="${REDIRECT}" Location="https://idp.test/slo"/>
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso"/>
</IDPSSODescriptor>
</EntityDescriptor>`;
expect(() => parseIdpMetadata(unclosedInner)).to.throw(InvalidIdpMetadataError);
});
});

View File

@@ -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;
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, unknown> = {
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();
});
});
});

View File

@@ -0,0 +1,69 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
describe('disableCustomScripts', () => {
let mockLicense: sinon.SinonStubbedInstance<any>;
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;
});
});

View File

@@ -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<string, string>) => new Headers(headers),
},
{
label: 'DDP path (plain object)',
makeInput: (headers: Record<string, string>) => 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);
});
});
}
});

View File

@@ -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('<script>alert("XSS");</script>')).to.be.false;
expect(validateNameChars('%3Cscript%3Ealert%28%27XSS%27%29%3C%2Fscript%3E')).to.be.false;
expect(
validateNameChars(
'<form action="http://malicious.site" method="post"><input type="text" name="username" value="Enter username"><input type="password" name="password" value="Enter password"><input type="submit" value="Submit"></form>',
),
).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;
});
});

View File

@@ -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',
},
});
});
});
});

View File

@@ -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;
});
});
});

View File

@@ -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;
});
});
});

View File

@@ -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<void>;
type SendHelperFn = (user: { email?: string; password?: string; name?: string }) => Promise<void>;
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', '<p>html</p>', { ...userData, email: undefined });
Sinon.assert.notCalled(MailerStub.send);
});
it('should send email with correct parameters', async () => {
await sendUserEmail('subject', '<p>html</p>', 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: '<p>html</p>',
});
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', '<p>html</p>', 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', '<p>html</p>', { ...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,
});
});
});
});

View File

@@ -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;
});
});
});

View File

@@ -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<div>$</div>');
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;
});
});
});

View File

@@ -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);
});
});

View File

@@ -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<void>((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<void>((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<void>((resolve) => responseBody.once('close', resolve));
writeStream.destroy(new Error('stream failed'));
await responseBodyClosed;
await new Promise<void>((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<void>((resolve) => writeStream.once('finish', resolve));
responseBody.end('file contents');
await finished;
expect(stubs.updateProgress.calledWith(progressStep.FILE_LOADED)).to.be.true;
});
});

View File

@@ -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;
});
});

View File

@@ -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;
});
});

View File

@@ -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;
});
});

View File

@@ -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<any> {
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);
});
});
});
});

View File

@@ -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<any> {
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<string, unknown>;
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);
});
});

View File

@@ -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<IMessage, '_updatedAt'> = {
_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<IMessage> & { _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<IMessage, '_updatedAt'> = {
_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<IMessage, '_id' | 'ts' | '_updatedAt'> => ({
_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<IMessage, '_id' | 'ts' | '_updatedAt'> => ({
_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<unknown>): Promise<any> => {
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 } });
});
});

View File

@@ -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);
});
});

View File

@@ -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<T> = {
toArray(): Promise<T[]>;
map<U>(callback: (item: T) => U): CursorResult<U>;
};
const cursor = <T>(items: T[]): CursorResult<T> => ({
toArray: async () => items,
map: (callback) => cursor(items.map(callback)),
});
const settings: Record<string, unknown> = {
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<typeof AISearchService> => 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;
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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;
});
});
});

View File

@@ -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<any> {
findActiveByRoleOrId(
_roles: string[],
_platform: BannerPlatform,
_bannerId?: string,
_options?: FindOptions<IBanner>,
): FindCursor<IBanner> {
return {} as unknown as FindCursor<IBanner>;
}
}
class BannerDismissModel extends BaseRaw<any> {
findByUserIdAndBannerId(): FindCursor<Pick<IBannerDismiss, 'bannerId'>> {
return {} as unknown as FindCursor<Pick<IBannerDismiss, 'bannerId'>>;
}
}
class UserModel extends BaseRaw<any> {
override findOneById(): Promise<any> {
return Promise.resolve({});
}
}
function findCursorFactory<T>(items: T[]): FindCursor<T> {
return {
toArray: () => Promise.resolve(items),
} as unknown as FindCursor<T>;
}
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<IBanner>([FAKE_BANNER])),
);
const findByUserIdAndBannerIdMock = sinon.replace(
bannerDismissModel,
'findByUserIdAndBannerId',
sinon.fake.returns(findCursorFactory<Pick<IBannerDismiss, 'bannerId'>>([])),
);
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<IBanner>([FAKE_BANNER])),
);
const findByUserIdAndBannerIdMock = sinon.replace(
bannerDismissModel,
'findByUserIdAndBannerId',
sinon.fake.returns(findCursorFactory<Pick<IBannerDismiss, 'bannerId'>>([{ 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<IBanner>([A_SECOND_FAKE_BANNER])),
);
const findByUserIdAndBannerIdMock = sinon.replace(
bannerDismissModel,
'findByUserIdAndBannerId',
sinon.fake.returns(findCursorFactory<Pick<IBannerDismiss, 'bannerId'>>([])),
);
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');
});
});
});

View File

@@ -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<string>();
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);
};
}

View File

@@ -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<string, any>();
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<typeof CalendarService>;
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<InstanceType<typeof CalendarService>>(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);
});
});
});

View File

@@ -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());
});
});

View File

@@ -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;
});
});
});

View File

@@ -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');
});
});

View File

@@ -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;
});
});

View File

@@ -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<IMessage> = {}) => ({
_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',
});
});
});

View File

@@ -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');
});
});

View File

@@ -0,0 +1,114 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
const Authorization = {
hasPermission: async (_uid: string, _permission: string, _room?: string): Promise<boolean> => 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);
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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);
});
});
});

View File

@@ -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' },
]);
});
});
});

View File

@@ -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 },
];

View File

@@ -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, any>): 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<string, any> = {
'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<string, any> = {
'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<string, any> = {
'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<string, any> = {
'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<string, any> = {
'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<string, any> = {
'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<string, any> = {
'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;
});
});

View File

@@ -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;
});
});
});

View File

@@ -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',
},
});
});
});

Some files were not shown because too many files have changed in this diff Show More