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,153 @@
import { RoomType } from '@rocket.chat/apps-engine/definition/rooms';
import { SettingType } from '@rocket.chat/apps-engine/definition/settings';
import { UserStatusConnection, UserType } from '@rocket.chat/apps-engine/definition/users';
import { expect } from 'chai';
import { describe, it } from 'mocha';
import * as z from 'zod';
import {
RoomTypeCodec,
SettingTypeCodec,
UserStatusConnectionCodec,
UserTypeCodec,
} from '../../../../../../app/apps/server/converters/codecs/enums';
/*
* These are copies of the legacy private helpers the codecs replace. They act as the oracle:
* `decode` must produce identical output for every input the converters could see.
*/
function legacyUserType(type: string | undefined): string {
switch (type) {
case 'user':
return UserType.USER;
case 'bot':
return UserType.BOT;
case 'app':
return UserType.APP;
case '':
case undefined:
return UserType.UNKNOWN;
default:
return type.toUpperCase();
}
}
function legacyStatusConnection(status: string | undefined): string {
switch (status) {
case 'offline':
return UserStatusConnection.OFFLINE;
case 'online':
return UserStatusConnection.ONLINE;
case 'away':
return UserStatusConnection.AWAY;
case 'busy':
return UserStatusConnection.BUSY;
case undefined:
return UserStatusConnection.UNDEFINED;
default:
return !status ? UserStatusConnection.OFFLINE : status.toUpperCase();
}
}
function legacyRoomType(typeChar: string): string {
switch (typeChar) {
case 'c':
return RoomType.CHANNEL;
case 'p':
return RoomType.PRIVATE_GROUP;
case 'd':
return RoomType.DIRECT_MESSAGE;
case 'l':
return RoomType.LIVE_CHAT;
default:
return typeChar;
}
}
function legacySettingType(type: string): string {
switch (type) {
case 'boolean':
return SettingType.BOOLEAN;
case 'code':
return SettingType.CODE;
case 'color':
return SettingType.COLOR;
case 'font':
return SettingType.FONT;
case 'int':
return SettingType.NUMBER;
case 'select':
return SettingType.SELECT;
case 'string':
return SettingType.STRING;
default:
return type;
}
}
describe('apps converter enum codecs', () => {
describe('UserTypeCodec', () => {
const inputs = ['user', 'bot', 'app', '', undefined, 'newfangled'];
it('decode matches the legacy _convertUserTypeToEnum for every input', () => {
for (const input of inputs) {
expect(z.decode(UserTypeCodec, input)).to.equal(legacyUserType(input));
}
});
it('encode passes the Apps-Engine value straight through', () => {
for (const value of Object.values(UserType)) {
expect(z.encode(UserTypeCodec, value)).to.equal(value);
}
});
});
describe('UserStatusConnectionCodec', () => {
const inputs = ['offline', 'online', 'away', 'busy', '', undefined, 'weird'];
it('decode matches the legacy _convertStatusConnectionToEnum for every input', () => {
for (const input of inputs) {
expect(z.decode(UserStatusConnectionCodec, input)).to.equal(legacyStatusConnection(input));
}
});
it('encode passes the Apps-Engine value straight through', () => {
for (const value of Object.values(UserStatusConnection)) {
expect(z.encode(UserStatusConnectionCodec, value)).to.equal(value);
}
});
});
describe('RoomTypeCodec', () => {
const inputs = ['c', 'p', 'd', 'l', 'v'];
it('decode matches the legacy _convertTypeToApp for every input', () => {
for (const input of inputs) {
expect(z.decode(RoomTypeCodec, input)).to.equal(legacyRoomType(input));
}
});
it('encode passes the Apps-Engine value straight through', () => {
for (const value of Object.values(RoomType)) {
expect(z.encode(RoomTypeCodec, value)).to.equal(value);
}
});
});
describe('SettingTypeCodec', () => {
const inputs = ['boolean', 'code', 'color', 'font', 'int', 'select', 'string', 'multiSelect'];
it('decode matches the legacy _convertTypeToApp for every input', () => {
for (const input of inputs) {
expect(z.decode(SettingTypeCodec, input)).to.equal(legacySettingType(input));
}
});
it('encode passes the Apps-Engine value straight through', () => {
for (const value of Object.values(SettingType)) {
expect(z.encode(SettingTypeCodec, value)).to.equal(value);
}
});
});
});

View File

@@ -0,0 +1,238 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import * as z from 'zod';
import { createMappedCodec, mappedDecode, mappedDecodeAsync } from '../../../../../../app/apps/server/converters/codecs/mappedData';
describe('createMappedCodec', () => {
const codec = createMappedCodec({
id: '_id',
name: 'name',
scope: 'scope',
});
it('decode renames mapped fields and buckets the rest', () => {
const result = z.decode(codec, { _id: 'r1', name: 'admin', scope: 'Users', extra: 1, _updatedAt: 'x' });
expect(result).to.deep.equal({
id: 'r1',
name: 'admin',
scope: 'Users',
_unmappedProperties_: { extra: 1, _updatedAt: 'x' },
});
});
it('decode omits mapped targets whose source is undefined but still consumes the source key', () => {
const result = z.decode(codec, { _id: 'r1', name: undefined, other: true });
expect(result).to.deep.equal({
id: 'r1',
_unmappedProperties_: { other: true },
});
expect(result).to.not.have.nested.property('_unmappedProperties_.name');
});
it('decode does not mutate the input (deep isolation)', () => {
const input = { _id: 'r1', nested: { a: 1 } };
const result = z.decode(codec, input) as any;
result._unmappedProperties_.nested.a = 999;
expect(input.nested.a).to.equal(1);
});
it('encode applies the inverse rename and merges _unmappedProperties_', () => {
const result = z.encode(codec, { id: 'r1', name: 'admin', scope: 'Users', _unmappedProperties_: { extra: 1 } });
expect(result).to.deep.equal({ _id: 'r1', name: 'admin', scope: 'Users', extra: 1 });
});
it('round-trips decode -> encode back to the original shape', () => {
const original = { _id: 'r1', name: 'admin', scope: 'Users', extra: 1 };
expect(z.encode(codec, z.decode(codec, original))).to.deep.equal(original);
});
});
describe('mappedDecodeAsync', () => {
it('handles string renames, sync and async derived fields, and buckets the rest', async () => {
const result = await mappedDecodeAsync(
{ _id: 'r1', t: 'c', u: { _id: 'u1' }, leftover: 42 },
{
id: '_id',
type: (data) => {
const { t } = data;
delete data.t;
return t === 'c' ? 'channel' : t;
},
creator: async (data) => {
const { u } = data;
delete data.u;
return u ? { id: u._id } : undefined;
},
},
);
expect(result).to.deep.equal({
id: 'r1',
type: 'channel',
creator: { id: 'u1' },
_unmappedProperties_: { leftover: 42 },
});
});
it('omits derived fields that return undefined and does not mutate the input', async () => {
const input = { _id: 'r1', nested: { a: 1 } };
const result = await mappedDecodeAsync(input, {
id: '_id',
creator: () => undefined,
});
expect(result).to.deep.equal({ id: 'r1', _unmappedProperties_: { nested: { a: 1 } } });
expect(result).to.not.have.property('creator');
(result as any)._unmappedProperties_.nested.a = 999;
expect(input.nested.a).to.equal(1);
});
it('maps a nested object recursively, bucketing unmapped fields at each level', async () => {
const result = await mappedDecodeAsync(
{ _id: 'c1', visitor: { visitorId: 'v1', extra: 'drop' }, spare: true },
{
_id: '_id',
visitor: { from: 'visitor', map: { visitorId: 'visitorId' } },
},
);
expect(result).to.deep.equal({
_id: 'c1',
visitor: { visitorId: 'v1', _unmappedProperties_: { extra: 'drop' } },
_unmappedProperties_: { spare: true },
});
});
it('omits a nested object whose source is missing without a property access error', async () => {
const result = await mappedDecodeAsync(
{ _id: 'c1', spare: true },
{
_id: '_id',
lastChat: { from: 'lastChat', map: { _id: '_id', ts: 'ts' } },
visitor: { from: 'visitor', map: { visitorId: 'visitorId' } },
details: { from: 'details', map: { type: 'type' } },
},
);
expect(result).to.deep.equal({ _id: 'c1', _unmappedProperties_: { spare: true } });
expect(result).to.not.have.property('lastChat');
expect(result).to.not.have.property('visitor');
expect(result).to.not.have.property('details');
});
it('omits a nested object whose source is null without a property access error', async () => {
const result = await mappedDecodeAsync(
{ _id: 'c1', lastChat: null, visitor: null, details: null, spare: true },
{
_id: '_id',
lastChat: { from: 'lastChat', map: { _id: '_id', ts: 'ts' } },
visitor: { from: 'visitor', map: { visitorId: 'visitorId' } },
details: { from: 'details', map: { type: 'type' } },
},
);
expect(result).to.deep.equal({ _id: 'c1', _unmappedProperties_: { spare: true } });
expect(result).to.not.have.property('lastChat');
expect(result).to.not.have.property('visitor');
expect(result).to.not.have.property('details');
});
it('drops the _unmappedProperties_ bucket at the root and every nested level when `dropUnmapped` is set', async () => {
const result = await mappedDecodeAsync(
{ _id: 'c1', visitor: { visitorId: 'v1', extra: 'drop' }, phones: [{ phoneNumber: '1', extra: 'x' }], spare: true },
{
_id: '_id',
visitor: { from: 'visitor', map: { visitorId: 'visitorId' } },
phones: { from: 'phones', list: true, map: { phoneNumber: 'phoneNumber' } },
},
{ dropUnmapped: true },
);
expect(result).to.deep.equal({
_id: 'c1',
visitor: { visitorId: 'v1' },
phones: [{ phoneNumber: '1' }],
});
});
it('copies a nested source verbatim when the descriptor has no `map`', async () => {
const result = await mappedDecodeAsync(
{ _id: 'c1', details: { type: 'sms', id: 'sms-1' }, spare: true },
{
_id: '_id',
details: { from: 'details' },
},
);
expect(result).to.deep.equal({
_id: 'c1',
details: { type: 'sms', id: 'sms-1' },
_unmappedProperties_: { spare: true },
});
});
it('maps a list of objects via `list` + `map`, bucketing each element', async () => {
const result = await mappedDecodeAsync(
{ phones: [{ phoneNumber: '1', extra: 'x' }, { phoneNumber: '2' }] },
{ phones: { from: 'phones', list: true, map: { phoneNumber: 'phoneNumber' } } },
);
expect(result).to.deep.equal({
phones: [
{ phoneNumber: '1', _unmappedProperties_: { extra: 'x' } },
{ phoneNumber: '2', _unmappedProperties_: {} },
],
_unmappedProperties_: {},
});
});
it('drops a source property from the bucket when a function deletes it explicitly', async () => {
const result = await mappedDecodeAsync(
{ firstName: 'John', lastName: 'Doe' },
{
fullName: (d: Record<string, any>) => {
const value = `${d.firstName} ${d.lastName}`;
delete d.firstName;
return value;
},
},
);
expect(result).to.deep.equal({ fullName: 'John Doe', _unmappedProperties_: { lastName: 'Doe' } });
});
it('wraps a non-array value into a list when `list` is true', async () => {
const result = await mappedDecodeAsync({ tag: 'admin' }, { tags: { from: 'tag', list: true, map: {} } });
expect(result).to.deep.equal({ tags: ['admin'], _unmappedProperties_: {} });
});
it('keeps every property as unmapped when the map is empty', async () => {
const result = await mappedDecodeAsync({ os: 'android', version: '1.9', lan: 'en' }, {});
expect(result).to.deep.equal({ _unmappedProperties_: { os: 'android', version: '1.9', lan: 'en' } });
});
});
describe('mappedDecode type constraints', () => {
type Source = { _id: string; name: string };
it('constrains map values to keys of the source type and infers the decoded shape', () => {
const decode = mappedDecode<Source>({ id: '_id', label: 'name' });
const result = decode({ _id: 'a', name: 'b' });
// `result` is typed `Decoded<Source, ...>`: renamed targets plus the bucket.
expect(result).to.deep.equal({ id: 'a', label: 'b', _unmappedProperties_: {} });
// @ts-expect-error - 'missing' is not a key of Source, so the field map is rejected.
mappedDecode<Source>({ id: 'missing' });
});
});

View File

@@ -0,0 +1,52 @@
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
let visitor: Record<string, unknown>;
const { appRoomToRocketChat } = proxyquire.noCallThru().load('../../../../../../app/apps/server/converters/codecs/rooms', {
'@rocket.chat/models': {
LivechatVisitors: {
findOneEnabledById: async () => visitor,
},
LivechatDepartment: { findOneById: async () => undefined },
Users: { findOneById: async () => undefined },
LivechatContacts: { findOneEnabledById: async () => undefined },
},
});
describe('appRoomToRocketChat', () => {
beforeEach(() => {
visitor = { _id: 'visitor-1', username: 'guest', token: 'tok-1', status: 'online' };
});
it('does not throw when a visitor is present but visitorChannelInfo is missing', async () => {
const result = (await appRoomToRocketChat({ id: 'lc-1', type: 'l', visitor: { id: 'visitor-1' } }, false)) as Record<string, any>;
expect(result.v).to.include({ _id: 'visitor-1' });
expect(result.v).to.not.have.property('lastMessageTs');
expect(result.v).to.not.have.property('phone');
});
it('includes the activity when the visitor has a non-empty activity array', async () => {
visitor.activity = ['2026-09'];
const result = (await appRoomToRocketChat({ id: 'lc-1', type: 'l', visitor: { id: 'visitor-1' } }, false)) as Record<string, any>;
expect(result.v).to.have.property('activity').that.deep.equals(['2026-09']);
});
it('omits the activity when the visitor activity is null', async () => {
visitor.activity = null;
const result = (await appRoomToRocketChat({ id: 'lc-1', type: 'l', visitor: { id: 'visitor-1' } }, false)) as Record<string, any>;
expect(result.v).to.not.have.property('activity');
});
it('omits the activity when the visitor has no activity', async () => {
const result = (await appRoomToRocketChat({ id: 'lc-1', type: 'l', visitor: { id: 'visitor-1' } }, false)) as Record<string, any>;
expect(result.v).to.not.have.property('activity');
});
});

View File

@@ -0,0 +1,65 @@
import type { IAppsUser } from '@rocket.chat/apps';
import type { ISetting, IUser } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { AppSettingsConverter } from '../../../../../app/apps/server/converters/settings';
import { AppUsersConverter } from '../../../../../app/apps/server/converters/users';
const orch: any = {};
const asUser = (value: Record<string, unknown>) => value as unknown as IUser;
const asAppUser = (value: Record<string, unknown>) => value as unknown as IAppsUser;
const asSetting = (value: Record<string, unknown>) => value as unknown as ISetting;
describe('apps converters — lenient enum edge cases', () => {
describe('AppUsersConverter.convertToApp', () => {
const converter = new AppUsersConverter(orch);
it('maps an undefined statusConnection to UNDEFINED (Livechat guests / rocket.cat)', () => {
const result = converter.convertToApp(asUser({ _id: 'u', username: 'a', statusConnection: undefined }));
expect(result.statusConnection).to.equal('undefined');
expect(result.type).to.equal('unknown');
});
it('does not throw and falls back to OFFLINE for a null statusConnection', () => {
const result = converter.convertToApp(asUser({ _id: 'u', username: 'a', statusConnection: null }));
expect(result.statusConnection).to.equal('offline');
});
});
describe('AppUsersConverter.convertToRocketChat', () => {
const converter = new AppUsersConverter(orch);
it('does not throw when the app user has no type/statusConnection and strips empties', () => {
const result = converter.convertToRocketChat(asAppUser({ id: 'u', username: 'a' }));
expect(result).to.deep.equal({ _id: 'u', username: 'a' });
});
it('falls back to the legacy utfOffset property when utcOffset is absent', () => {
const result = converter.convertToRocketChat(asAppUser({ id: 'u', username: 'a', utfOffset: -3 }));
expect(result).to.deep.equal({ _id: 'u', username: 'a', utcOffset: -3 });
});
it('prefers utcOffset over the legacy utfOffset when both are present', () => {
const result = converter.convertToRocketChat(asAppUser({ id: 'u', username: 'a', utcOffset: 5, utfOffset: -3 }));
expect(result).to.deep.equal({ _id: 'u', username: 'a', utcOffset: 5 });
});
});
describe('AppSettingsConverter.convertToApp', () => {
const converter = new AppSettingsConverter(orch);
it('passes an unknown/undefined setting type through without throwing', () => {
const result = converter.convertToApp(asSetting({ _id: 's', type: undefined }));
expect(result.id).to.equal('s');
expect(result.type).to.equal(undefined);
});
});
});

View File

@@ -0,0 +1,904 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { AppContactsConverter } from '../../../../../app/apps/server/converters/contacts';
import { AppDepartmentsConverter } from '../../../../../app/apps/server/converters/departments';
import { AppMessagesConverter } from '../../../../../app/apps/server/converters/messages';
import { AppRolesConverter } from '../../../../../app/apps/server/converters/roles';
import { AppRoomsConverter } from '../../../../../app/apps/server/converters/rooms';
import { AppSettingsConverter } from '../../../../../app/apps/server/converters/settings';
import { AppThreadsConverter } from '../../../../../app/apps/server/converters/threads';
import { AppUploadsConverter } from '../../../../../app/apps/server/converters/uploads';
import { AppUsersConverter } from '../../../../../app/apps/server/converters/users';
import { AppVideoConferencesConverter } from '../../../../../app/apps/server/converters/videoConferences';
import { AppVisitorsConverter } from '../../../../../app/apps/server/converters/visitors';
/*
* Golden snapshots of the converters' deterministic transform methods, captured from the
* behaviour that existed before the Zod-codec migration. These lock the RC <-> Apps-Engine field
* mapping (including the `_unmappedProperties_` bucket and pass-through rules) so that each phase
* of the migration can prove it preserves behaviour exactly.
*
* Output is compared after `JSON.parse(JSON.stringify(...))` so Date instances are normalised to
* ISO strings and `undefined` fields are dropped — matching how these payloads cross the app bridge.
*/
const ts = new Date('2024-01-01T00:00:00.000Z');
const updated = new Date('2024-02-02T00:00:00.000Z');
const json = (value: unknown): unknown => JSON.parse(JSON.stringify(value));
// A stub orchestrator whose cross-converter lookups resolve deterministically, so the decode
// direction of the cross-converter converters (uploads/rooms/messages) can be snapshotted without a
// database.
const stubOrch: any = {
getConverters: () => ({
get: (key: string) => ({
convertById: async (id: string) => ({ __converted: key, id }),
convertToApp: (u: any) => ({ __convertedToApp: key, id: u?._id }),
convertByToken: async (token: string) => ({ __converted: key, token }),
}),
}),
};
describe('apps converters — golden snapshots (pre-codec behaviour)', () => {
describe('AppSettingsConverter', () => {
it('convertToApp maps a setting to the Apps-Engine shape', () => {
const converter = new AppSettingsConverter(stubOrch);
const setting = {
_id: 'Some_Setting',
type: 'int',
packageValue: 10,
values: [{ key: 'a', i18nLabel: 'A' }],
value: 42,
public: true,
hidden: false,
group: 'General',
i18nLabel: 'Some_Setting',
i18nDescription: 'Some_Setting_Description',
ts,
_updatedAt: updated,
sorter: 5,
};
expect(json(converter.convertToApp(setting as any))).to.deep.equal({
id: 'Some_Setting',
type: 'int',
packageValue: 10,
values: [{ key: 'a', i18nLabel: 'A' }],
value: 42,
public: true,
hidden: false,
group: 'General',
i18nLabel: 'Some_Setting',
i18nDescription: 'Some_Setting_Description',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-02-02T00:00:00.000Z',
});
});
});
describe('AppUsersConverter', () => {
const converter = new AppUsersConverter(stubOrch);
it('convertToApp maps a user to the Apps-Engine shape', () => {
const user = {
_id: 'user-1',
username: 'john.doe',
emails: [{ address: 'john@example.com', verified: true }],
type: 'bot',
active: true,
name: 'John Doe',
roles: ['admin', 'user'],
bio: 'hello',
status: 'online',
statusText: 'working',
statusConnection: 'online',
utcOffset: -3,
createdAt: ts,
_updatedAt: updated,
lastLogin: ts,
appId: 'app-1',
customFields: { foo: 'bar' },
federated: false,
federation: undefined,
freeSwitchExtension: '1001',
settings: { preferences: { language: 'en' } },
};
expect(json(converter.convertToApp(user as any))).to.deep.equal({
id: 'user-1',
username: 'john.doe',
emails: [{ address: 'john@example.com', verified: true }],
type: 'bot',
isEnabled: true,
name: 'John Doe',
roles: ['admin', 'user'],
bio: 'hello',
status: 'online',
statusText: 'working',
statusConnection: 'online',
utcOffset: -3,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-02-02T00:00:00.000Z',
lastLoginAt: '2024-01-01T00:00:00.000Z',
appId: 'app-1',
customFields: { foo: 'bar' },
isFederated: false,
sipExtension: '1001',
settings: { preferences: { language: 'en' } },
});
});
it('convertToRocketChat maps an app user back to the RC shape', () => {
const appUser = {
id: 'user-1',
username: 'john.doe',
emails: [{ address: 'john@example.com', verified: true }],
type: 'bot',
isEnabled: true,
name: 'John Doe',
roles: ['admin', 'user'],
bio: 'hello',
status: 'online',
statusConnection: 'online',
utcOffset: -3,
createdAt: ts,
updatedAt: updated,
lastLoginAt: ts,
appId: 'app-1',
isFederated: false,
federation: undefined,
sipExtension: '1001',
};
expect(json(converter.convertToRocketChat(appUser as any))).to.deep.equal({
_id: 'user-1',
username: 'john.doe',
emails: [{ address: 'john@example.com', verified: true }],
type: 'bot',
active: true,
name: 'John Doe',
roles: ['admin', 'user'],
bio: 'hello',
status: 'online',
statusConnection: 'online',
utcOffset: -3,
createdAt: '2024-01-01T00:00:00.000Z',
_updatedAt: '2024-02-02T00:00:00.000Z',
lastLogin: '2024-01-01T00:00:00.000Z',
appId: 'app-1',
federated: false,
freeSwitchExtension: '1001',
});
});
});
describe('AppVisitorsConverter', () => {
const converter = new AppVisitorsConverter(stubOrch);
it('convertVisitor maps a visitor and buckets unmapped fields', async () => {
const visitor = {
_id: 'visitor-1',
username: 'guest-1',
name: 'Guest One',
department: 'dep-1',
_updatedAt: updated,
token: 'tok-abc',
phone: [{ phoneNumber: '+15551234' }],
visitorEmails: [{ address: 'guest@example.com' }],
livechatData: { note: 'vip' },
status: 'online',
activity: ['2024-01'],
externalIds: ['ext-1'],
lastChat: { _id: 'room-1', ts },
};
expect(json(await converter.convertVisitor(visitor as any))).to.deep.equal({
id: 'visitor-1',
username: 'guest-1',
name: 'Guest One',
department: 'dep-1',
updatedAt: '2024-02-02T00:00:00.000Z',
token: 'tok-abc',
phone: [{ phoneNumber: '+15551234' }],
visitorEmails: [{ address: 'guest@example.com' }],
livechatData: { note: 'vip' },
status: 'online',
activity: ['2024-01'],
externalIds: ['ext-1'],
_unmappedProperties_: { lastChat: { _id: 'room-1', ts: '2024-01-01T00:00:00.000Z' } },
});
});
it('convertAppVisitor maps an app visitor back and merges unmapped fields', () => {
const appVisitor = {
id: 'visitor-1',
username: 'guest-1',
name: 'Guest One',
token: 'tok-abc',
phone: [{ phoneNumber: '+15551234' }],
livechatData: { note: 'vip' },
status: 'online',
visitorEmails: [{ address: 'guest@example.com' }],
department: 'dep-1',
externalIds: ['ext-1'],
_unmappedProperties_: { customField: 'x' },
};
expect(json(converter.convertAppVisitor(appVisitor as any))).to.deep.equal({
_id: 'visitor-1',
username: 'guest-1',
name: 'Guest One',
token: 'tok-abc',
phone: [{ phoneNumber: '+15551234' }],
livechatData: { note: 'vip' },
status: 'online',
visitorEmails: [{ address: 'guest@example.com' }],
department: 'dep-1',
externalIds: ['ext-1'],
customField: 'x',
});
});
});
describe('AppDepartmentsConverter', () => {
const converter = new AppDepartmentsConverter(stubOrch);
it('convertDepartment maps a department and buckets unmapped fields', async () => {
const department = {
_id: 'dep-1',
name: 'Support',
email: 'support@example.com',
_updatedAt: updated,
enabled: true,
numAgents: 3,
showOnOfflineForm: true,
description: 'Support dept',
offlineMessageChannelName: 'offline',
requestTagBeforeClosingChat: true,
chatClosingTags: ['done'],
abandonedRoomsCloseCustomMessage: 'bye',
waitingQueueMessage: 'please wait',
departmentsAllowedToForward: ['dep-2'],
showOnRegistration: false,
type: 'd',
};
expect(json(await converter.convertDepartment(department as any))).to.deep.equal({
id: 'dep-1',
name: 'Support',
email: 'support@example.com',
updatedAt: '2024-02-02T00:00:00.000Z',
enabled: true,
numberOfAgents: 3,
showOnOfflineForm: true,
description: 'Support dept',
offlineMessageChannelName: 'offline',
requestTagBeforeClosingChat: true,
chatClosingTags: ['done'],
abandonedRoomsCloseCustomMessage: 'bye',
waitingQueueMessage: 'please wait',
departmentsAllowedToForward: ['dep-2'],
showOnRegistration: false,
_unmappedProperties_: { type: 'd' },
});
});
it('convertAppDepartment maps an app department back and merges unmapped fields', () => {
const appDepartment = {
id: 'dep-1',
name: 'Support',
email: 'support@example.com',
updatedAt: updated,
enabled: true,
numberOfAgents: 3,
showOnOfflineForm: true,
showOnRegistration: false,
description: 'Support dept',
offlineMessageChannelName: 'offline',
requestTagBeforeClosingChat: true,
chatClosingTags: ['done'],
abandonedRoomsCloseCustomMessage: 'bye',
waitingQueueMessage: 'please wait',
departmentsAllowedToForward: ['dep-2'],
_unmappedProperties_: { extra: 1 },
};
expect(json(converter.convertAppDepartment(appDepartment as any))).to.deep.equal({
_id: 'dep-1',
name: 'Support',
email: 'support@example.com',
_updatedAt: '2024-02-02T00:00:00.000Z',
enabled: true,
numAgents: 3,
showOnOfflineForm: true,
showOnRegistration: false,
description: 'Support dept',
offlineMessageChannelName: 'offline',
requestTagBeforeClosingChat: true,
chatClosingTags: ['done'],
abandonedRoomsCloseCustomMessage: 'bye',
waitingQueueMessage: 'please wait',
departmentsAllowedToForward: ['dep-2'],
extra: 1,
});
});
});
describe('AppRolesConverter', () => {
it('convertRole maps a role and buckets unmapped fields', async () => {
const converter = new AppRolesConverter();
const role = {
_id: 'admin',
name: 'admin',
description: 'Administrator',
mandatory2fa: true,
protected: true,
scope: 'Users',
_updatedAt: updated,
};
expect(json(await converter.convertRole(role as any))).to.deep.equal({
id: 'admin',
name: 'admin',
description: 'Administrator',
mandatory2fa: true,
protected: true,
scope: 'Users',
_unmappedProperties_: { _updatedAt: '2024-02-02T00:00:00.000Z' },
});
});
});
describe('AppVideoConferencesConverter', () => {
const converter = new AppVideoConferencesConverter();
const call = {
_id: 'vc-1',
type: 'direct',
rid: 'room-1',
status: 1,
createdBy: { _id: 'user-1', username: 'john.doe' },
createdAt: ts,
};
const expected = {
_id: 'vc-1',
type: 'direct',
rid: 'room-1',
status: 1,
createdBy: { _id: 'user-1', username: 'john.doe' },
createdAt: '2024-01-01T00:00:00.000Z',
};
it('convertVideoConference passes the call through', () => {
expect(json(converter.convertVideoConference(call as any))).to.deep.equal(expected);
});
it('convertAppVideoConference passes the call through', () => {
expect(json(converter.convertAppVideoConference(call as any))).to.deep.equal(expected);
});
});
describe('AppContactsConverter', () => {
const converter = new AppContactsConverter();
it('convertContact clones the contact unchanged', async () => {
const contact = {
_id: 'contact-1',
_updatedAt: updated,
name: 'Jane',
phones: [{ phoneNumber: '+15550000' }],
emails: [{ address: 'jane@example.com' }],
unknown: false,
customFields: { tier: 'gold' },
createdAt: ts,
channels: [
{
name: 'sms',
verified: true,
visitor: { visitorId: 'visitor-1', source: { type: 'sms', id: 'sms-1' } },
blocked: false,
lastChat: { _id: 'room-1', ts },
},
],
importIds: ['imp-1'],
};
expect(json(await converter.convertContact(contact as any))).to.deep.equal(json(contact));
});
it('convertAppContact deep-maps an app contact back, dropping unmapped fields at each level', async () => {
const appContact = {
_id: 'contact-1',
_updatedAt: updated,
name: 'Jane',
phones: [{ phoneNumber: '+15550000', extra: 'drop-me' }],
emails: [{ address: 'jane@example.com' }],
contactManager: 'mgr-1',
unknown: false,
conflictingFields: [{ field: 'name', value: 'Janet' }],
customFields: { tier: 'gold' },
channels: [
{
name: 'sms',
verified: true,
visitor: { visitorId: 'visitor-1', source: { type: 'sms', id: 'sms-1' } },
blocked: false,
field: 'phone',
value: '+15550000',
verifiedAt: ts,
details: { type: 'sms', id: 'sms-1', alias: 'a', label: 'l', sidebarIcon: 'i', defaultIcon: 'd', destination: 'x' },
lastChat: { _id: 'room-1', ts },
},
],
createdAt: ts,
lastChat: { _id: 'room-1', ts },
importIds: ['imp-1'],
};
expect(json(await converter.convertAppContact(appContact as any))).to.deep.equal({
_id: 'contact-1',
_updatedAt: '2024-02-02T00:00:00.000Z',
name: 'Jane',
phones: [{ phoneNumber: '+15550000' }],
emails: [{ address: 'jane@example.com' }],
contactManager: 'mgr-1',
unknown: false,
conflictingFields: [{ field: 'name', value: 'Janet' }],
customFields: { tier: 'gold' },
channels: [
{
name: 'sms',
verified: true,
visitor: {
visitorId: 'visitor-1',
source: { type: 'sms', id: 'sms-1' },
},
blocked: false,
field: 'phone',
value: '+15550000',
verifiedAt: '2024-01-01T00:00:00.000Z',
details: {
type: 'sms',
id: 'sms-1',
alias: 'a',
label: 'l',
sidebarIcon: 'i',
defaultIcon: 'd',
destination: 'x',
},
lastChat: { _id: 'room-1', ts: '2024-01-01T00:00:00.000Z' },
},
],
createdAt: '2024-01-01T00:00:00.000Z',
lastChat: { _id: 'room-1', ts: '2024-01-01T00:00:00.000Z' },
importIds: ['imp-1'],
});
});
});
describe('AppUploadsConverter', () => {
const converter = new AppUploadsConverter(stubOrch);
it('convertToApp maps an upload and resolves room/user/visitor via converters', async () => {
const upload = {
_id: 'up-1',
name: 'file.png',
size: 1234,
type: 'image/png',
store: 'GridFS',
description: 'a file',
complete: true,
uploading: false,
extension: 'png',
progress: 1,
etag: 'etag-1',
path: '/path',
token: 'up-tok',
url: 'http://example/file.png',
_updatedAt: updated,
uploadedAt: ts,
rid: 'room-1',
userId: 'user-1',
visitorToken: 'tok-abc',
};
expect(json(await converter.convertToApp(upload as any))).to.deep.equal({
id: 'up-1',
name: 'file.png',
size: 1234,
type: 'image/png',
store: 'GridFS',
description: 'a file',
complete: true,
uploading: false,
extension: 'png',
progress: 1,
etag: 'etag-1',
path: '/path',
token: 'up-tok',
url: 'http://example/file.png',
updatedAt: '2024-02-02T00:00:00.000Z',
uploadedAt: '2024-01-01T00:00:00.000Z',
room: { __converted: 'rooms', id: 'room-1' },
user: { __converted: 'users', id: 'user-1' },
visitor: { __converted: 'visitors', token: 'tok-abc' },
_unmappedProperties_: {},
});
});
it('convertToRocketChat maps an app upload back and merges unmapped fields', () => {
const appUpload = {
id: 'up-1',
name: 'file.png',
size: 1234,
type: 'image/png',
extension: 'png',
description: 'a file',
store: 'GridFS',
etag: 'etag-1',
path: '/path',
complete: true,
uploading: false,
progress: 1,
token: 'up-tok',
url: 'http://example/file.png',
updatedAt: updated,
uploadedAt: ts,
room: { id: 'room-1' },
user: { id: 'user-1' },
visitor: { token: 'tok-abc' },
_unmappedProperties_: { extraUpload: true },
};
expect(json(converter.convertToRocketChat(appUpload as any))).to.deep.equal({
_id: 'up-1',
name: 'file.png',
size: 1234,
type: 'image/png',
extension: 'png',
description: 'a file',
store: 'GridFS',
etag: 'etag-1',
complete: true,
uploading: false,
progress: 1,
token: 'up-tok',
url: 'http://example/file.png',
_updatedAt: '2024-02-02T00:00:00.000Z',
uploadedAt: '2024-01-01T00:00:00.000Z',
rid: 'room-1',
userId: 'user-1',
visitorToken: 'tok-abc',
extraUpload: true,
});
});
it('convertToRocketChat does not throw when the app upload has no room (e.g. deleted room)', () => {
const appUpload = {
id: 'up-1',
name: 'file.png',
user: { id: 'user-1' },
};
const result = json(converter.convertToRocketChat(appUpload as any)) as Record<string, unknown>;
expect(result).to.deep.equal({ _id: 'up-1', name: 'file.png', userId: 'user-1' });
expect(result).to.not.have.property('rid');
});
});
});
/*
* Decode-direction golden snapshots for the async, cross-converter converters (rooms, messages,
* threads). These are the highest-risk converters for mapping drift (the `transformMappedData` ->
* `mappedDecodeAsync` swap), yet previously had no golden coverage. Cross-converter lookups resolve
* through `stubOrch`, so these snapshots run without a database.
*/
describe('apps converters — decode golden snapshots (rooms/messages/threads)', () => {
describe('AppRoomsConverter.convertRoom', () => {
const converter = new AppRoomsConverter(stubOrch);
it('maps a channel room: renames, boolean/type function fields, creator lookup, bucket', async () => {
const result = await converter.convertRoom({
_id: 'GENERAL',
t: 'c',
fname: 'General',
name: 'general',
u: { _id: 'owner-1' },
msgs: 10,
ts,
_updatedAt: updated,
default: true,
ro: false,
customFields: { a: 1 },
teamId: 'team-1',
teamMain: true,
federated: false,
lastMessage: { _id: 'lm-1' },
} as any);
expect(json(result)).to.deep.equal({
id: 'GENERAL',
displayName: 'General',
slugifiedName: 'general',
messageCount: 10,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-02-02T00:00:00.000Z',
customFields: { a: 1 },
teamId: 'team-1',
isTeamMain: true,
isFederated: false,
isDefault: true,
isReadOnly: false,
displaySystemMessages: true,
type: 'c',
creator: { __converted: 'users', id: 'owner-1' },
_unmappedProperties_: { lastMessage: { _id: 'lm-1' } },
});
});
it('maps a livechat room: visitor/contact/department/closedBy/servedBy/responseBy/parentRoom + secure fields', async () => {
const result = await converter.convertRoom({
_id: 'lc-1',
t: 'l',
fname: 'Chat',
msgs: 3,
ts,
_updatedAt: updated,
closer: 'user',
closedBy: { _id: 'closer-1' },
servedBy: { _id: 'agent-1' },
responseBy: { _id: 'resp-1' },
v: { _id: 'visitor-1', phone: '+15550000', lastMessageTs: ts },
departmentId: 'dep-1',
contactId: 'contact-1',
prid: 'parent-1',
open: true,
waitingResponse: true,
sysMes: false,
abacAttributes: { level: 'secret' },
spare: 'keep',
} as any);
expect(json(result)).to.deep.equal({
'id': 'lc-1',
'displayName': 'Chat',
'messageCount': 3,
'createdAt': '2024-01-01T00:00:00.000Z',
'updatedAt': '2024-02-02T00:00:00.000Z',
'isWaitingResponse': true,
'isOpen': true,
'closer': 'user',
'isDefault': false,
'isReadOnly': false,
'displaySystemMessages': false,
'type': 'l',
'visitor': { __converted: 'visitors', id: 'visitor-1' },
'contact': { __converted: 'contacts', id: 'contact-1' },
'visitorChannelInfo': { phone: '+15550000', lastMessageTs: '2024-01-01T00:00:00.000Z' },
'department': { __converted: 'departments', id: 'dep-1' },
'closedBy': { __converted: 'users', id: 'closer-1' },
'servedBy': { __converted: 'users', id: 'agent-1' },
'responseBy': { __converted: 'users', id: 'resp-1' },
'parentRoom': { __converted: 'rooms', id: 'parent-1' },
'@@SecureFields': [{ permission: 'abac.read', name: 'abacAttributes', value: { level: 'secret' } }],
'_unmappedProperties_': {
v: { _id: 'visitor-1', phone: '+15550000', lastMessageTs: '2024-01-01T00:00:00.000Z' },
contactId: 'contact-1',
spare: 'keep',
},
});
});
});
describe('AppRoomsConverter.convertRoomRaw', () => {
const converter = new AppRoomsConverter(stubOrch);
it('maps a raw room without cross-converter resolution (inline user/visitor lookups)', async () => {
const result = await converter.convertRoomRaw({
_id: 'lc-2',
t: 'l',
fname: 'Raw',
msgs: 1,
ts,
_updatedAt: updated,
v: { _id: 'visitor-2', username: 'guest', extra: 'x' },
closer: 'visitor',
closedBy: { _id: 'visitor-2', username: 'guest' },
u: { _id: 'owner-2', username: 'owner', name: 'Owner' },
leftover: true,
} as any);
expect(json(result)).to.deep.equal({
id: 'lc-2',
displayName: 'Raw',
messageCount: 1,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-02-02T00:00:00.000Z',
closer: 'visitor',
visitor: { id: 'visitor-2', username: 'guest', extra: 'x' },
displaySystemMessages: true,
type: 'l',
creator: { _id: 'owner-2', username: 'owner', name: 'Owner' },
closedBy: { _id: 'visitor-2', username: 'guest' },
_unmappedProperties_: { leftover: true },
});
});
});
describe('AppMessagesConverter.convertMessage', () => {
const converter = new AppMessagesConverter(stubOrch);
it('maps a message: room/editor/sender lookups, attachment sub-map, bucket', async () => {
const result = await converter.convertMessage({
_id: 'msg-1',
rid: 'GENERAL',
tmid: 'thread-1',
u: { _id: 'user-1', username: 'u1' },
msg: 'hello',
ts,
_updatedAt: updated,
t: 'p',
editedBy: { _id: 'editor-1' },
editedAt: updated,
emoji: ':smile:',
alias: 'alias-1',
groupable: false,
attachments: [
{
text: 'attachment text',
ts,
author_name: 'Author',
author_link: 'http://a',
author_icon: 'http://i',
title: 'Title',
title_link: 'http://t',
image_url: 'http://img',
fields: [{ title: 'f', value: 'v' }],
},
],
spare: 'keep',
} as any);
expect(json(result)).to.deep.equal({
id: 'msg-1',
threadId: 'thread-1',
text: 'hello',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-02-02T00:00:00.000Z',
editedAt: '2024-02-02T00:00:00.000Z',
emoji: ':smile:',
alias: 'alias-1',
groupable: false,
type: 'p',
room: { __converted: 'rooms', id: 'GENERAL' },
editor: { __converted: 'users', id: 'editor-1' },
attachments: [
{
text: 'attachment text',
imageUrl: 'http://img',
fields: [{ title: 'f', value: 'v' }],
author: { name: 'Author', link: 'http://a', icon: 'http://i' },
title: { value: 'Title', link: 'http://t' },
timestamp: '2024-01-01T00:00:00.000Z',
_unmappedProperties_: {},
},
],
sender: { __converted: 'users', id: 'user-1' },
_unmappedProperties_: { spare: 'keep' },
});
});
it('sender falls back to the original message.u when the primary lookup resolves to nothing', async () => {
// Old system messages from visitors have no `token`, so the primary lookup can miss; the
// fallback must still receive the original sender rather than the already-deleted `message.u`.
const fallbackOrch: any = {
getConverters: () => ({
get: (key: string) => ({
convertById: async () => undefined,
convertToApp: (u: any) => (u ? { __fallback: key, id: u._id } : undefined),
convertByToken: async () => undefined,
}),
}),
};
const fallbackConverter = new AppMessagesConverter(fallbackOrch);
const result = await fallbackConverter.convertMessage({
_id: 'msg-3',
rid: 'GENERAL',
u: { _id: 'user-9', username: 'u9' },
msg: 'hi',
ts,
_updatedAt: updated,
t: 'p',
} as any);
expect((json(result) as any).sender).to.deep.equal({ __fallback: 'users', id: 'user-9' });
});
});
describe('AppMessagesConverter.convertMessageRaw', () => {
const converter = new AppMessagesConverter(stubOrch);
it('maps a raw message (roomId/editor/sender/threadMsgCount pass-through, bucket)', async () => {
const result = await converter.convertMessageRaw({
_id: 'msg-2',
rid: 'GENERAL',
u: { _id: 'user-2' },
msg: 'raw',
ts,
_updatedAt: updated,
t: 'p',
tcount: 4,
editedBy: { _id: 'editor-2' },
leftover: 1,
} as any);
expect(json(result)).to.deep.equal({
id: 'msg-2',
text: 'raw',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-02-02T00:00:00.000Z',
roomId: 'GENERAL',
editor: { _id: 'editor-2' },
sender: { _id: 'user-2' },
threadMsgCount: 4,
type: 'p',
_unmappedProperties_: { leftover: 1 },
});
});
});
describe('AppThreadsConverter.convertMessage', () => {
const converter = new AppThreadsConverter(stubOrch);
const convertUserById = (async (id: string) => ({ __converted: 'users', id })) as any;
const convertToApp = ((u: any) => ({ __convertedToApp: 'users', id: u?._id })) as any;
it('maps a thread reply: injected room, editor/sender via passed-in lookups, attachment sub-map, bucket', async () => {
const result = await converter.convertMessage(
{
_id: 'tmsg-1',
rid: 'GENERAL',
tmid: 'thread-1',
u: { _id: 'user-3', username: 'u3' },
msg: 'reply',
ts,
_updatedAt: updated,
editedBy: { _id: 'editor-3' },
editedAt: updated,
attachments: [{ text: 'a', ts, title: 'T', title_link: 'http://t' }],
spare: 'keep',
} as any,
{ id: 'GENERAL' } as any,
convertUserById,
convertToApp,
);
expect(json(result)).to.deep.equal({
id: 'tmsg-1',
threadId: 'thread-1',
text: 'reply',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-02-02T00:00:00.000Z',
editedAt: '2024-02-02T00:00:00.000Z',
room: { id: 'GENERAL' },
attachments: [
{
text: 'a',
title: { value: 'T', link: 'http://t' },
timestamp: '2024-01-01T00:00:00.000Z',
_unmappedProperties_: {},
},
],
sender: { __converted: 'users', id: 'user-3' },
_unmappedProperties_: {
rid: 'GENERAL',
u: { _id: 'user-3', username: 'u3' },
editedBy: { _id: 'editor-3' },
spare: 'keep',
},
});
});
});
});

View File

@@ -0,0 +1,179 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import { appMessageMock, appMessageInvalidRoomMock, appPartialMessageMock } from './mocks/data/messages.data';
import { MessagesMock } from './mocks/models/Messages.mock';
import { RoomsMock } from './mocks/models/Rooms.mock';
import { UsersMock } from './mocks/models/Users.mock';
import { AppServerOrchestratorMock } from './mocks/orchestrator.mock';
const { AppMessagesConverter } = proxyquire.noCallThru().load('../../../../../app/apps/server/converters/messages', {
'@rocket.chat/random': {
Random: {
id: () => 1,
},
},
'@rocket.chat/models': {
Rooms: new RoomsMock(),
Messages: new MessagesMock(),
Users: new UsersMock(),
},
'@rocket.chat/core-typings': {
isMessageFromVisitor: (message) => 'token' in message,
},
});
describe('The AppMessagesConverter instance', () => {
let messagesConverter;
let messagesMock;
before(() => {
const orchestrator = new AppServerOrchestratorMock();
const usersConverter = orchestrator.getConverters().get('users');
usersConverter.convertById = function convertUserByIdStub(id) {
return UsersMock.convertedData[id];
};
usersConverter.convertToApp = function convertUserToAppStub(user) {
return {
id: user._id,
username: user.username,
name: user.name,
};
};
orchestrator.getConverters().get('rooms').convertById = async function convertRoomByIdStub(id) {
return RoomsMock.convertedData[id];
};
messagesConverter = new AppMessagesConverter(orchestrator);
messagesMock = new MessagesMock();
});
const createdAt = new Date('2019-03-30T01:22:08.389Z');
const updatedAt = new Date('2019-03-30T01:22:08.412Z');
describe('when converting a message from Rocket.Chat to the Engine schema', () => {
it('should return `undefined` when `msgObj` is falsy', async () => {
const appMessage = await messagesConverter.convertMessage(undefined);
expect(appMessage).to.be.undefined;
});
it('should return a proper schema', async () => {
const appMessage = await messagesConverter.convertMessage(messagesMock.findOneById('SimpleMessageMock'));
expect(appMessage).to.have.property('id', 'SimpleMessageMock');
expect(appMessage).to.have.property('createdAt').which.equalTime(createdAt);
expect(appMessage).to.have.property('updatedAt').which.equalTime(updatedAt);
expect(appMessage).to.have.property('groupable', false);
expect(appMessage).to.have.property('sender').which.includes({ id: 'rocket.cat' });
expect(appMessage).to.have.property('room').which.includes({ id: 'GENERAL' });
expect(appMessage).not.to.have.property('editor');
expect(appMessage).not.to.have.property('attachments');
expect(appMessage).not.to.have.property('reactions');
expect(appMessage).not.to.have.property('avatarUrl');
expect(appMessage).not.to.have.property('alias');
expect(appMessage).not.to.have.property('customFields');
expect(appMessage).not.to.have.property('emoji');
});
it('should not mutate the original message object', () => {
const rocketchatMessageMock = messagesMock.findOneById('SimpleMessageMock');
messagesConverter.convertMessage(rocketchatMessageMock);
expect(rocketchatMessageMock).to.deep.equal({
_id: 'SimpleMessageMock',
t: 'uj',
rid: 'GENERAL',
ts: new Date('2019-03-30T01:22:08.389Z'),
msg: 'rocket.cat',
u: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
groupable: false,
_updatedAt: new Date('2019-03-30T01:22:08.412Z'),
});
});
it("should return basic sender info when it's not a Rocket.Chat user (e.g. Livechat Guest)", async () => {
const appMessage = await messagesConverter.convertMessage(messagesMock.findOneById('LivechatGuestMessageMock'));
expect(appMessage).to.have.property('sender').which.includes({
id: 'guest1234',
username: 'guest1234',
name: 'Livechat Guest',
});
});
});
describe('when converting a message from the Engine schema back to Rocket.Chat', () => {
it('should return `undefined` when `message` is falsy', async () => {
const rocketchatMessage = await messagesConverter.convertAppMessage(undefined);
expect(rocketchatMessage).to.be.undefined;
});
it('should return a proper schema', async () => {
const rocketchatMessage = await messagesConverter.convertAppMessage(appMessageMock);
expect(rocketchatMessage).to.have.property('_id', 'appMessageMock');
expect(rocketchatMessage).to.have.property('rid', 'GENERAL');
expect(rocketchatMessage).to.have.property('groupable', false);
expect(rocketchatMessage).to.have.property('ts').which.equalTime(createdAt);
expect(rocketchatMessage).to.have.property('_updatedAt').which.equalTime(updatedAt);
expect(rocketchatMessage).to.have.property('u').which.includes({
_id: 'rocket.cat',
username: 'rocket.cat',
name: 'Rocket.Cat',
});
});
it('should return a proper schema when receiving a partial object', async () => {
const rocketchatMessage = await messagesConverter.convertAppMessage(appPartialMessageMock, true);
expect(rocketchatMessage).to.have.property('_id', 'appPartialMessageMock');
expect(rocketchatMessage).to.have.property('groupable', false);
expect(rocketchatMessage).to.have.property('emoji', ':smirk:');
expect(rocketchatMessage).to.have.property('alias', 'rocket.feline');
expect(rocketchatMessage).to.not.have.property('ts');
expect(rocketchatMessage).to.not.have.property('u');
expect(rocketchatMessage).to.not.have.property('rid');
expect(rocketchatMessage).to.not.have.property('_updatedAt');
});
it('should merge `_unmappedProperties_` into the returned message', async () => {
const rocketchatMessage = await messagesConverter.convertAppMessage(appMessageMock);
expect(rocketchatMessage).not.to.have.property('_unmappedProperties_');
expect(rocketchatMessage).to.have.property('t', 'uj');
});
it('should not merge `_unmappedProperties_` into the returned message when receiving a partial object', async () => {
const invalidPartialMessage = structuredClone(appPartialMessageMock);
invalidPartialMessage._unmappedProperties_ = {
t: 'uj',
};
const rocketchatMessage = await messagesConverter.convertAppMessage(invalidPartialMessage, true);
expect(rocketchatMessage).to.not.have.property('_unmappedProperties_');
expect(rocketchatMessage).to.not.have.property('t');
});
it('should throw if message has an invalid room', async () => {
try {
await messagesConverter.convertAppMessage(appMessageInvalidRoomMock);
} catch (e) {
expect(e).to.be.an.instanceOf(Error);
expect(e.message).to.equal('Invalid room provided on the message.');
}
});
});
});

View File

@@ -0,0 +1,5 @@
export class BaseModelMock {
findOneById(id) {
return this.data[id];
}
}

View File

@@ -0,0 +1,36 @@
import { BaseModelMock } from './BaseModel.mock';
export class MessagesMock extends BaseModelMock {
data = {
SimpleMessageMock: {
_id: 'SimpleMessageMock',
t: 'uj',
rid: 'GENERAL',
ts: new Date('2019-03-30T01:22:08.389Z'),
msg: 'rocket.cat',
u: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
groupable: false,
_updatedAt: new Date('2019-03-30T01:22:08.412Z'),
},
LivechatGuestMessageMock: {
_id: 'LivechatGuestMessageMock',
rid: 'LivechatRoom',
msg: 'Help wanted',
token: 'guest-token',
alias: 'Livechat Guest',
ts: new Date('2019-04-06T03:57:28.263Z'),
u: {
_id: 'guest1234',
username: 'guest1234',
name: 'Livechat Guest',
},
_updatedAt: new Date('2019-04-06T03:57:28.278Z'),
mentions: [],
channels: [],
},
};
}

View File

@@ -0,0 +1,147 @@
import { BaseModelMock } from './BaseModel.mock';
export class RoomsMock extends BaseModelMock {
data = {
GENERAL: {
_id: 'GENERAL',
ts: new Date('2019-03-27T20:51:36.808Z'),
t: 'c',
name: 'general',
usernames: [],
msgs: 31,
usersCount: 3,
default: true,
_updatedAt: new Date('2019-04-10T17:44:34.931Z'),
lastMessage: {
_id: 1,
t: 'uj',
rid: 'GENERAL',
ts: new Date('2019-03-30T01:22:08.389Z'),
msg: 'rocket.cat',
u: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
groupable: false,
_updatedAt: new Date('2019-03-30T01:22:08.412Z'),
},
lm: new Date('2019-04-10T17:44:34.873Z'),
},
LivechatRoom: {
_id: 'LivechatRoom',
msgs: 41,
usersCount: 1,
lm: new Date('2019-04-07T23:45:25.407Z'),
fname: 'Livechat Guest',
t: 'l',
ts: new Date('2019-04-06T03:56:17.040Z'),
v: {
_id: 'yDLaWs5Rzf5mzQsmB',
username: 'guest-4',
token: 'tkps932ccsl6me7intd3',
status: 'away',
},
servedBy: {
_id: 'rocket.cat',
username: 'rocket.cat',
ts: new Date('2019-04-06T03:56:17.040Z'),
},
cl: false,
open: true,
_updatedAt: new Date('2019-04-07T23:45:25.469Z'),
lastMessage: {
_id: 'zgEMhaMLCyDPu7xMn',
rid: 'JceP6CZrpcA4j3NNe',
msg: 'a',
ts: new Date('2019-04-07T23:45:25.407Z'),
u: {
_id: '3Wz2wANqwrd7Hu5Fo',
username: 'dgubert',
name: 'Douglas Gubert',
},
_updatedAt: new Date('2019-04-07T23:45:25.433Z'),
mentions: [],
channels: [],
},
metrics: {
v: {
lq: new Date('2019-04-06T03:57:28.263Z'),
},
reaction: {
fd: new Date('2019-04-06T03:57:17.083Z'),
ft: 60.043,
tt: 52144.278,
},
response: {
avg: 26072.0655,
fd: new Date('2019-04-06T03:57:17.083Z'),
ft: 59.896,
total: 2,
tt: 52144.131,
},
servedBy: {
lr: new Date('2019-04-06T18:25:32.394Z'),
},
},
responseBy: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
},
};
static convertedData = {
GENERAL: {
id: 'GENERAL',
slugifiedName: 'general',
displayName: undefined,
creator: undefined,
createdAt: new Date('2019-03-27T20:51:36.808Z'),
type: 'c',
messageCount: 31,
displaySystemMessages: true,
isReadOnly: false,
isDefault: true,
updatedAt: new Date('2019-04-10T17:44:34.931Z'),
lastModifiedAt: new Date('2019-04-10T17:44:34.873Z'),
customFields: {},
},
GENERALPartial: {
id: 'GENERAL',
slugifiedName: 'general',
displaySystemMessages: true,
updatedAt: new Date('2019-04-10T17:44:34.931Z'),
},
GENERALPartialWithOptionalProps: {
id: 'GENERAL',
slugifiedName: 'general',
displaySystemMessages: true,
updatedAt: new Date('2019-04-10T17:44:34.931Z'),
messageCount: 40,
type: 'c',
},
UpdatedRoom: {
customFields: { custom: 'field' },
},
LivechatRoom: {
id: 'LivechatRoom',
slugifiedName: undefined,
displayName: 'Livechat Guest',
creator: undefined,
createdAt: new Date('2019-04-06T03:56:17.040Z'),
type: 'l', // Apps-Engine defines the wrong type for livechat rooms
messageCount: 41,
displaySystemMessages: true,
isReadOnly: false,
isDefault: false,
updatedAt: new Date('2019-04-07T23:45:25.469Z'),
lastModifiedAt: new Date('2019-04-07T23:45:25.407Z'),
customFields: {},
},
};
}

View File

@@ -0,0 +1,43 @@
import { BaseModelMock } from './BaseModel.mock';
export class UsersMock extends BaseModelMock {
data = {
'rocket.cat': {
_id: 'rocket.cat',
createdAt: new Date('2019-03-27T20:51:36.821Z'),
avatarOrigin: 'local',
name: 'Rocket.Cat',
username: 'rocket.cat',
status: 'online',
statusDefault: 'online',
utcOffset: 0,
active: true,
type: 'bot',
_updatedAt: new Date('2019-03-30T01:11:50.496Z'),
roles: ['bot'],
},
};
static convertedData = {
'rocket.cat': {
id: 'rocket.cat',
username: 'rocket.cat',
emails: [
{
address: 'rocketcat@rocket.chat',
verified: true,
},
],
type: 'bot',
isEnabled: true,
name: 'Rocket.Cat',
roles: ['bot'],
status: 'online',
statusConnection: 'online',
utcOffset: 0,
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: undefined,
},
};
}

View File

@@ -0,0 +1,7 @@
import { MessagesMock } from './Messages.mock';
import { RoomsMock } from './Rooms.mock';
import { UsersMock } from './Users.mock';
export const Messages = new MessagesMock();
export const Rooms = new RoomsMock();
export const Users = new UsersMock();

View File

@@ -0,0 +1,108 @@
/* istanbul ignore file */
export class AppServerOrchestratorMock {
constructor() {
this._marketplaceClient = {};
this._model = {};
this._logModel = {};
this._persistModel = {};
this._storage = {};
this._logStorage = {};
this._converters = new Map();
this._converters.set('messages', {});
this._converters.set('rooms', {});
this._converters.set('settings', {});
this._converters.set('users', {});
this._bridges = {};
this._manager = {};
this._communicators = new Map();
this._communicators.set('methods', {});
this._communicators.set('notifier', {});
this._communicators.set('restapi', {});
}
getMarketplaceClient() {
return this._marketplaceClient;
}
getModel() {
return this._model;
}
getPersistenceModel() {
return this._persistModel;
}
getStorage() {
return this._storage;
}
getLogStorage() {
return this._logStorage;
}
getConverters() {
return this._converters;
}
getBridges() {
return this._bridges;
}
getNotifier() {
return this._communicators.get('notifier');
}
getManager() {
return this._manager;
}
isEnabled() {
return true;
}
isLoaded() {
return this.getManager().areAppsLoaded();
}
isDebugging() {
return true;
}
debugLog(...args) {
if (this.isDebugging()) {
console.log(...args);
}
}
load() {
// Don't try to load it again if it has
// already been loaded
if (this.isLoaded()) {
return;
}
this._manager
.load()
.then((affs) => console.log(`Loaded the Apps Framework and loaded a total of ${affs.length} Apps!`))
.catch((err) => console.warn('Failed to load the Apps Framework and Apps!', err));
}
unload() {
// Don't try to unload it if it's already been
// unlaoded or wasn't unloaded to start with
if (!this.isLoaded()) {
return;
}
this._manager
.unload()
.then(() => console.log('Unloaded the Apps Framework.'))
.catch((err) => console.warn('Failed to unload the Apps Framework!', err));
}
}

View File

@@ -0,0 +1,177 @@
import type { IAppRoomsConverter, IAppsRoom } from '@rocket.chat/apps';
import type { IRoom } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { before, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import { MessagesMock } from './mocks/models/Messages.mock';
import { RoomsMock } from './mocks/models/Rooms.mock';
import { UsersMock } from './mocks/models/Users.mock';
import { AppServerOrchestratorMock } from './mocks/orchestrator.mock';
const { AppRoomsConverter } = proxyquire.noCallThru().load('../../../../../app/apps/server/converters/rooms', {
'@rocket.chat/random': {
Random: {
id: () => 1,
},
},
'@rocket.chat/models': {
Rooms: new RoomsMock(),
Messages: new MessagesMock(),
Users: new UsersMock(),
},
});
describe('The AppMessagesConverter instance', () => {
let roomConverter: IAppRoomsConverter;
let roomsMock: RoomsMock;
before(() => {
const orchestrator = new AppServerOrchestratorMock();
const usersConverter = orchestrator.getConverters().get('users');
usersConverter.convertById = function convertUserByIdStub(id: string) {
return UsersMock.convertedData[id as 'rocket.cat'] || undefined;
};
usersConverter.convertToApp = function convertUserToAppStub(user: UsersMock['data']['rocket.cat']) {
return {
id: user._id,
username: user.username,
name: user.name,
};
};
orchestrator.getConverters().get('messages').convertById = async function convertRoomByIdStub(_id: string) {
return {};
};
roomConverter = new AppRoomsConverter(orchestrator);
roomsMock = new RoomsMock();
});
describe('when converting a room from Rocket.Chat to the Engine schema', () => {
it('should return `undefined` when `originalRoom` is falsy', async () => {
const appRoom = await roomConverter.convertRoom(undefined);
expect(appRoom).to.be.undefined;
});
it('should return a proper schema', async () => {
const mockedRoom = roomsMock.findOneById('GENERAL') as RoomsMock['data']['GENERAL'];
const appRoom = await roomConverter.convertRoom(mockedRoom as unknown as IRoom);
expect(appRoom).to.have.property('id', mockedRoom._id);
expect(appRoom).to.have.property('type', mockedRoom.t);
expect(appRoom).to.have.property('slugifiedName', mockedRoom.name);
expect(appRoom).to.have.property('createdAt').which.equalTime(mockedRoom.ts);
expect(appRoom).to.have.property('updatedAt').which.equalTime(mockedRoom._updatedAt);
expect(appRoom).to.have.property('messageCount', mockedRoom.msgs);
});
it('should not mutate the original room object', async () => {
const rocketchatRoomMock = structuredClone(roomsMock.findOneById('GENERAL'));
await roomConverter.convertRoom(rocketchatRoomMock);
expect(rocketchatRoomMock).to.deep.equal(roomsMock.findOneById('GENERAL'));
});
it('should add an `_unmappedProperties_` field to the converted room which contains the `lastMessage` property of the room', async () => {
const mockedRoom = roomsMock.findOneById('GENERAL') as RoomsMock['data']['GENERAL'];
const appMessage = await roomConverter.convertRoom(mockedRoom as unknown as IRoom);
expect(appMessage).to.have.property('_unmappedProperties_').which.has.property('lastMessage').to.deep.equal(mockedRoom.lastMessage);
});
});
describe('when converting a room from the Engine schema back to Rocket.Chat', () => {
it('should return `undefined` when `room` is falsy', async () => {
const rocketchatMessage = await roomConverter.convertAppRoom(undefined);
expect(rocketchatMessage).to.be.undefined;
});
it('should return a proper schema', async () => {
const appRoom = RoomsMock.convertedData.GENERAL as unknown as IAppsRoom;
const rocketchatRoom = await roomConverter.convertAppRoom(appRoom);
expect(rocketchatRoom).to.have.property('_id', appRoom.id);
expect(rocketchatRoom).to.have.property('ts', appRoom.createdAt);
expect(rocketchatRoom).to.have.property('lm', appRoom.lastModifiedAt);
expect(rocketchatRoom).to.have.property('_updatedAt', appRoom.updatedAt);
expect(rocketchatRoom).to.have.property('t', appRoom.type);
expect(rocketchatRoom).to.have.property('name', appRoom.slugifiedName);
});
it('should return a proper schema when receiving a partial object', async () => {
const appRoom = RoomsMock.convertedData.GENERALPartial as unknown as IAppsRoom;
const rocketchatRoom = await roomConverter.convertAppRoom(appRoom, true);
expect(rocketchatRoom).to.have.property('_id', appRoom.id);
expect(rocketchatRoom).to.have.property('name', appRoom.slugifiedName);
expect(rocketchatRoom).to.have.property('sysMes', appRoom.displaySystemMessages);
expect(rocketchatRoom).to.have.property('_updatedAt', appRoom.updatedAt);
expect(rocketchatRoom).to.not.have.property('msgs');
expect(rocketchatRoom).to.not.have.property('ro');
expect(rocketchatRoom).to.not.have.property('default');
expect(rocketchatRoom).to.not.have.property('t');
});
it('should return a proper schema when receiving a partial object', async () => {
const appRoom = RoomsMock.convertedData.GENERALPartialWithOptionalProps as unknown as IAppsRoom;
const rocketchatRoom = await roomConverter.convertAppRoom(appRoom, true);
expect(rocketchatRoom).to.have.property('_id', appRoom.id);
expect(rocketchatRoom).to.have.property('name', appRoom.slugifiedName);
expect(rocketchatRoom).to.have.property('sysMes', appRoom.displaySystemMessages);
expect(rocketchatRoom).to.have.property('_updatedAt', appRoom.updatedAt);
expect(rocketchatRoom).to.have.property('msgs', appRoom.messageCount);
expect(rocketchatRoom).to.have.property('t', 'c');
expect(rocketchatRoom).to.not.have.property('ro');
expect(rocketchatRoom).to.not.have.property('default');
});
it('should not include properties that are not present in the app room', async () => {
const appRoom = RoomsMock.convertedData.UpdatedRoom as unknown as IAppsRoom;
const rocketchatRoom = await roomConverter.convertAppRoom(appRoom, true);
expect(rocketchatRoom).to.have.property('customFields');
expect(rocketchatRoom).to.not.have.property('_id');
expect(rocketchatRoom).to.not.have.property('t');
});
it('should not include name as undefined if the room doesnt have a name property', async () => {
const appRoom = RoomsMock.convertedData.UpdatedRoom as unknown as IAppsRoom;
const rocketchatRoom = await roomConverter.convertAppRoom(appRoom, true);
expect(rocketchatRoom.name).to.be.undefined;
});
it('should include a name if the source room has slugifiedName property', async () => {
const appRoom = RoomsMock.convertedData.GENERALPartialWithOptionalProps as unknown as IAppsRoom;
const rocketchatRoom = await roomConverter.convertAppRoom(appRoom, true);
expect(rocketchatRoom.name).to.equal(appRoom.slugifiedName);
});
it('should not use _unmappedProperties when the room is a partial object', async () => {
const appRoom = RoomsMock.convertedData.GENERALPartialWithOptionalProps as unknown as IAppsRoom;
// @ts-expect-error - _unmappedProperties
const rocketchatRoom = await roomConverter.convertAppRoom({ ...appRoom, _unmappedProperties_: { unmapped: 'property' } }, true);
expect(rocketchatRoom).to.not.have.property('unmapped');
});
it('should use _unmappedProperties when the room is a partial object', async () => {
const appRoom = RoomsMock.convertedData.GENERALPartialWithOptionalProps as unknown as IAppsRoom;
// @ts-expect-error - _unmappedProperties
const rocketchatRoom = await roomConverter.convertAppRoom({ ...appRoom, _unmappedProperties_: { unmapped: 'property' } }, false);
expect(rocketchatRoom).to.have.property('unmapped', 'property');
});
});
});

View File

@@ -0,0 +1,70 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { getEmojiData } from '../../../../app/emoji-native/lib/generateEmojiData';
import { getEmojiConfig } from '../../../../app/emoji-native/lib/getEmojiConfig';
import { shortnameToUnicode } from '../../../../app/emoji-native/lib/shortnameToUnicode';
describe('emoji-native shortcode resolution', () => {
describe('shortnameToUnicode', () => {
it('resolves skin-tone variants stored under emojione alternate names', () => {
expect(shortnameToUnicode(':thumbsup_tone3:')).to.equal('👍🏽');
expect(shortnameToUnicode(':thumbup_tone3:')).to.equal('👍🏽');
expect(shortnameToUnicode(':clap_tone2:')).to.equal('👏🏼');
expect(shortnameToUnicode(':pray_tone4:')).to.equal('🙏🏾');
});
it('still resolves the emojibase primary skin-tone name', () => {
expect(shortnameToUnicode(':+1_tone3:')).to.equal('👍🏽');
});
it('resolves regional indicator letters', () => {
expect(shortnameToUnicode(':regional_indicator_a:')).to.equal('🇦');
expect(shortnameToUnicode(':regional_indicator_z:')).to.equal('🇿');
});
it('resolves legacy emojione base aliases', () => {
expect(shortnameToUnicode(':digit_zero:')).to.equal('0️⃣');
expect(shortnameToUnicode(':digit_nine:')).to.equal('9️⃣');
expect(shortnameToUnicode(':pound_symbol:')).to.equal('#️⃣');
expect(shortnameToUnicode(':asterisk_symbol:')).to.equal('*️⃣');
expect(shortnameToUnicode(':tone3:')).to.equal('🏽');
});
it('leaves unknown shortcodes untouched', () => {
expect(shortnameToUnicode(':not_a_real_emoji:')).to.equal(':not_a_real_emoji:');
});
it('resolves multiple shortcodes within a single string', () => {
expect(shortnameToUnicode('hi :thumbsup_tone3: and :regional_indicator_a:')).to.equal('hi 👍🏽 and 🇦');
});
});
describe('render', () => {
const { render } = getEmojiConfig({ list: {}, packages: {} });
it('renders a known shortcode as an emoji span', () => {
expect(render(':smile:')).to.contain('class="emoji"');
});
it('leaves prototype property names untouched', () => {
expect(render(':toString:')).to.equal(':toString:');
expect(render('topic :toString: end')).to.equal('topic :toString: end');
});
});
describe('getEmojiData', () => {
it('registers regional indicators in emojiList but keeps them out of the picker', () => {
const { emojiList, emojisByCategory } = getEmojiData();
expect(emojiList[':regional_indicator_a:']?.unicode).to.equal('🇦');
expect(emojisByCategory.flags).to.not.include('regional_indicator_a');
});
it('registers alternate skin-tone shortnames alongside the primary', () => {
const { emojiList } = getEmojiData();
expect(emojiList[':thumbsup_tone3:']?.unicode).to.equal('👍🏽');
expect(emojiList[':thumbup_tone3:']?.unicode).to.equal('👍🏽');
expect(emojiList[':+1_tone3:']?.unicode).to.equal('👍🏽');
});
});
});

View File

@@ -0,0 +1,136 @@
import { expect } from 'chai';
import { describe, it, beforeEach, before } from 'mocha';
import { getEmojiConfig } from '../../../../app/emoji-native/lib/getEmojiConfig';
import { getEmojisBySearchTerm, updateRecent, removeFromRecent, replaceEmojiInRecent } from '../../../../client/lib/emoji/helpers';
import { emoji } from '../../../../client/lib/emoji/lib';
const registerNativeEmojis = () => {
const config = getEmojiConfig(emoji);
emoji.packages.native = {
emojiCategories: config.emojiCategories as any,
emojisByCategory: config.emojisByCategory,
toneList: config.toneList,
render: config.render,
renderPicker: config.renderPicker,
sprites: config.sprites,
};
for (const [key, currentEmoji] of Object.entries(config.emojiList)) {
currentEmoji.emojiPackage = 'native';
emoji.list[key] = currentEmoji as any;
if (currentEmoji.shortnames) {
currentEmoji.shortnames.forEach((shortname: string) => {
emoji.list[shortname] = currentEmoji as any;
});
}
}
};
const registerCustomEmoji = (name: string) => {
if (!emoji.packages.emojiCustom) {
emoji.packages.emojiCustom = {
emojiCategories: [{ key: 'rocket', i18n: 'Custom' as any }],
emojisByCategory: { rocket: [] },
toneList: {},
render: (html: string) => html,
renderPicker: () => '<span class="emoji-custom" />',
} as any;
}
emoji.packages.emojiCustom.emojisByCategory.rocket.push(name);
emoji.list[`:${name}:`] = { emojiPackage: 'emojiCustom', name } as any;
};
describe('Emoji Client Helpers', () => {
beforeEach(() => {
emoji.packages.base.emojisByCategory.recent = [];
});
describe('getEmojisBySearchTerm', () => {
before(registerNativeEmojis);
const search = (term: string) => getEmojisBySearchTerm(term, 0, [], () => undefined);
const names = (term: string) => search(term).map((result) => result.emoji);
const rendersThumbsUp = (term: string) => search(term).some((result) => result.image?.includes('👍'));
it('finds an emoji by its primary shortcode', () => {
expect(names('+1')).to.include('+1');
});
it('finds an emoji by its first alias (thumbsup -> 👍)', () => {
expect(rendersThumbsUp('thumbsup')).to.be.true;
});
it('finds an emoji by a secondary alias (thumbup -> 👍)', () => {
expect(rendersThumbsUp('thumbup')).to.be.true;
});
it('matches aliases partially (thumb -> thumbsup)', () => {
expect(names('thumb')).to.include('thumbsup');
});
it('does not list the same emoji more than once', () => {
const images = search('grinning').map((result) => result.image);
expect(images.length).to.equal(new Set(images).size);
});
it('excludes skin-tone variants from the results', () => {
expect(names('+1').some((name) => /_tone[1-5]/.test(name))).to.be.false;
});
it('applies the selected skin tone when searching by an alias', () => {
const result = getEmojisBySearchTerm('thumbsup', 2, [], () => undefined).find(({ image }) => image?.includes('👍'));
expect(result).to.exist;
expect(result?.image).to.include('👍🏼');
expect(result?.emoji).to.equal('thumbsup_tone2');
});
it('finds a custom emoji whose name ends in a mixed skin-tone suffix (CORE-2473)', () => {
registerCustomEmoji('mycustom_tone1-2');
expect(names('mycustom_tone1-2')).to.include('mycustom_tone1-2');
});
});
describe('updateRecent', () => {
it('should update recent emojis with the provided emojis', () => {
const recentEmojis = ['emoji1', 'emoji2'];
updateRecent(recentEmojis);
expect(emoji.packages.base.emojisByCategory.recent).to.contain('emoji1');
expect(emoji.packages.base.emojisByCategory.recent).to.contain('emoji2');
});
});
describe('removeFromRecent', () => {
it('should remove a specific emoji from recent emojis', () => {
emoji.packages.base.emojisByCategory.recent = ['emoji1', 'emoji2', 'emoji3'];
removeFromRecent('emoji2', emoji.packages.base.emojisByCategory.recent);
expect(emoji.packages.base.emojisByCategory.recent).to.not.include('emoji2');
expect(emoji.packages.base.emojisByCategory.recent).to.deep.equal(['emoji1', 'emoji3']);
});
it('should do nothing if the emoji is not in the recent list', () => {
emoji.packages.base.emojisByCategory.recent = ['emoji1', 'emoji2'];
removeFromRecent('emoji3', emoji.packages.base.emojisByCategory.recent);
expect(emoji.packages.base.emojisByCategory.recent).to.deep.equal(['emoji1', 'emoji2']);
});
});
describe('replaceEmojiInRecent', () => {
it('should replace an existing emoji with a new one in recent emojis', () => {
emoji.packages.base.emojisByCategory.recent = ['emoji1', 'emoji2', 'emoji3'];
replaceEmojiInRecent({ oldEmoji: 'emoji2', newEmoji: 'emoji4' });
expect(emoji.packages.base.emojisByCategory.recent).to.not.include('emoji2');
expect(emoji.packages.base.emojisByCategory.recent).to.include('emoji4');
expect(emoji.packages.base.emojisByCategory.recent).to.deep.equal(['emoji1', 'emoji4', 'emoji3']);
});
it('should do nothing if the emoji to replace is not in the recent list', () => {
emoji.packages.base.emojisByCategory.recent = ['emoji1', 'emoji2'];
replaceEmojiInRecent({ oldEmoji: 'emoji3', newEmoji: 'emoji4' });
expect(emoji.packages.base.emojisByCategory.recent).to.deep.equal(['emoji1', 'emoji2']);
});
});
});

View File

@@ -0,0 +1,62 @@
import { expect } from 'chai';
import { highlightWords, getRegexHighlight, getRegexHighlightUrl } from '../../../../client/lib/highlightWords';
describe('helper', () => {
describe('highlightWords', () => {
it('highlights the correct words', () => {
const res = highlightWords(
'here is some word',
['word'].map((highlight) => ({
highlight,
regex: getRegexHighlight(highlight),
urlRegex: getRegexHighlightUrl(highlight),
})),
);
expect(res).to.be.equal('here is some <mark class="highlight-text">word</mark>');
});
describe('handles links', () => {
it('not highlighting one link', () => {
const res = highlightWords(
'here we go https://somedomain.com/here-some.word/pulls more words after',
['word'].map((highlight) => ({
highlight,
regex: getRegexHighlight(highlight),
urlRegex: getRegexHighlightUrl(highlight),
})),
);
expect(res).to.be.equal('here we go https://somedomain.com/here-some.word/pulls more words after');
});
it('not highlighting two links', () => {
const msg = 'here https://somedomain.com/here-some-foo/pulls more words after http://www.domain.com/some.foo/bar words after';
const res = highlightWords(
msg,
['foo'].map((highlight) => ({
highlight,
regex: getRegexHighlight(highlight),
urlRegex: getRegexHighlightUrl(highlight),
})),
);
expect(res).to.be.equal(msg);
});
it('not highlighting link but keep words on message highlighted', () => {
const res = highlightWords(
'here we go https://somedomain.com/here-some.foo/pulls more foo after',
['foo'].map((highlight) => ({
highlight,
regex: getRegexHighlight(highlight),
urlRegex: getRegexHighlightUrl(highlight),
})),
);
expect(res).to.be.equal('here we go https://somedomain.com/here-some.foo/pulls more <mark class="highlight-text">foo</mark> after');
});
});
});
});

View File

@@ -0,0 +1,24 @@
import proxyquire from 'proxyquire';
const mocks = {
'meteor/meteor': {
'Meteor': {
absoluteUrl() {
return 'http://localhost:3000/';
},
},
'@global': true,
},
'@rocket.chat/random': {
'Random': {
id() {
return Math.random().toString().replace('0.', 'A');
},
},
'@global': true,
},
};
export const { Markdown } = proxyquire.noCallThru().load('../../../../app/markdown/lib/markdown', mocks);
export const { original } = proxyquire.noCallThru().load('../../../../app/markdown/lib/parser/original/original', mocks);
export const { filtered } = proxyquire.noCallThru().load('../../../../app/markdown/lib/parser/filtered/filtered', mocks);

View File

@@ -0,0 +1,479 @@
import { escapeHTML } from '@rocket.chat/tools';
import { expect } from 'chai';
import { Markdown, original, filtered } from './client.mocks';
const wrapper = (text, tag) => `<span class="copyonly">${tag}</span>${text}<span class="copyonly">${tag}</span>`;
const boldWrapper = (text) => wrapper(`<strong>${text}</strong>`, '*');
const italicWrapper = (text) => wrapper(`<em>${text}</em>`, '_');
const strikeWrapper = (text) => wrapper(`<strike>${text}</strike>`, '~');
const headerWrapper = (text, level) => `<h${level}>${text}</h${level}>`;
const quoteWrapper = (text) =>
`<blockquote class="background-transparent-darker-before"><span class="copyonly">&gt;</span>${text}</blockquote>`;
const linkWrapped = (link, title) => `<a data-title="${link}" href="${link}" target="_blank" rel="noopener noreferrer">${title}</a>`;
const inlinecodeWrapper = (text) => wrapper(`<span><code class="code-colors inline">${text}</code></span>`, '`');
const codeWrapper = (text, lang) =>
`<pre><code class='code-colors hljs ${lang}'><span class='copyonly'>\`\`\`<br></span>${text}<span class='copyonly'><br>\`\`\`</span></code></pre>`;
const bold = {
'**': '**',
'* *': '* *',
'** *': '** *',
'** **': '** **',
'* Hello*': '* Hello*',
'*Hello *': '*Hello *',
':custom*emoji*case:': `:custom${boldWrapper('emoji')}case:`,
'text*hello*text': `text${boldWrapper('hello')}text`,
'*hello*text': `${boldWrapper('hello')}text`,
'text*hello*': `text${boldWrapper('hello')}`,
'*Hel lo*': boldWrapper('Hel lo'),
'*Hello*': boldWrapper('Hello'),
'**Hello**': boldWrapper('Hello'),
'**Hello*': `*${boldWrapper('Hello')}`,
'*Hello**': `${boldWrapper('Hello')}*`,
'Hello': 'Hello',
'*Hello': '*Hello',
'Hello*': 'Hello*',
'He*llo': 'He*llo',
'***Hello***': `*${boldWrapper('Hello')}*`,
'***Hello**': `*${boldWrapper('Hello')}`,
'*Hello* this is dog': `${boldWrapper('Hello')} this is dog`,
'Rocket cat says *Hello*': `Rocket cat says ${boldWrapper('Hello')}`,
'He said *Hello* to her': `He said ${boldWrapper('Hello')} to her`,
'**Hello** this is dog': `${boldWrapper('Hello')} this is dog`,
'Rocket cat says **Hello**': `Rocket cat says ${boldWrapper('Hello')}`,
'He said **Hello** to her': `He said ${boldWrapper('Hello')} to her`,
'He was a**nn**oyed': `He was a${boldWrapper('nn')}oyed`,
'There are two o in f*oo*tball': `There are two o in f${boldWrapper('oo')}tball`,
};
const italic = {
'__': '__',
'_ _': '_ _',
'__ _': '__ _',
'__ __': '__ __',
'_ Hello_': '_ Hello_',
'_Hello _': '_Hello _',
':custom_emoji_case:': ':custom_emoji_case:',
'text_hello_text': 'text_hello_text',
'_hello_text': '_hello_text',
'text_hello_': 'text_hello_',
'_Hel lo_': italicWrapper('Hel lo'),
'_Hello_': italicWrapper('Hello'),
'__Hello__': italicWrapper('Hello'),
'__Hello_': `_${italicWrapper('Hello')}`,
'_Hello__': `${italicWrapper('Hello')}_`,
'Hello': 'Hello',
'_Hello': '_Hello',
'Hello_': 'Hello_',
'He_llo': 'He_llo',
'___Hello___': `_${italicWrapper('Hello')}_`,
'___Hello__': `_${italicWrapper('Hello')}`,
'_Hello_ this is dog': `${italicWrapper('Hello')} this is dog`,
'Rocket cat says _Hello_': `Rocket cat says ${italicWrapper('Hello')}`,
'He said _Hello_ to her': `He said ${italicWrapper('Hello')} to her`,
'__Hello__ this is dog': `${italicWrapper('Hello')} this is dog`,
'Rocket cat says __Hello__': `Rocket cat says ${italicWrapper('Hello')}`,
'He said __Hello__ to her': `He said ${italicWrapper('Hello')} to her`,
};
const strike = {
'~~': '~~',
'~ ~': '~ ~',
'~~ ~': '~~ ~',
'~~ ~~': '~~ ~~',
'~ Hello~': '~ Hello~',
'~Hello ~': '~Hello ~',
':custom~emoji~case:': `:custom${strikeWrapper('emoji')}case:`,
'text~hello~text': `text${strikeWrapper('hello')}text`,
'~hello~text': `${strikeWrapper('hello')}text`,
'text~hello~': `text${strikeWrapper('hello')}`,
'~Hel lo~': strikeWrapper('Hel lo'),
'~Hello~': strikeWrapper('Hello'),
'~~Hello~~': strikeWrapper('Hello'),
'~~Hello~': `~${strikeWrapper('Hello')}`,
'~Hello~~': `${strikeWrapper('Hello')}~`,
'Hello': 'Hello',
'~Hello': '~Hello',
'Hello~': 'Hello~',
'He~llo': 'He~llo',
'~~~Hello~~~': `~${strikeWrapper('Hello')}~`,
'~~~Hello~~': `~${strikeWrapper('Hello')}`,
'~Hello~ this is dog': `${strikeWrapper('Hello')} this is dog`,
'Rocket cat says ~Hello~': `Rocket cat says ${strikeWrapper('Hello')}`,
'He said ~Hello~ to her': `He said ${strikeWrapper('Hello')} to her`,
'~~Hello~~ this is dog': `${strikeWrapper('Hello')} this is dog`,
'Rocket cat says ~~Hello~~': `Rocket cat says ${strikeWrapper('Hello')}`,
'He said ~~Hello~~ to her': `He said ${strikeWrapper('Hello')} to her`,
};
const headersLevel1 = {
'# Hello': headerWrapper('Hello', 1),
'# Rocket.Cat': headerWrapper('Rocket.Cat', 1),
'# Hi': headerWrapper('Hi', 1),
'# Hello this is dog': headerWrapper('Hello this is dog', 1),
'# Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 1),
'# He said Hello to her': headerWrapper('He said Hello to her', 1),
'#Hello': '#Hello',
'#Hello#': '#Hello#',
'He#llo': 'He#llo',
};
const headersLevel2 = {
'## Hello': headerWrapper('Hello', 2),
'## Rocket.Cat': headerWrapper('Rocket.Cat', 2),
'## Hi': headerWrapper('Hi', 2),
'## Hello this is dog': headerWrapper('Hello this is dog', 2),
'## Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 2),
'## He said Hello to her': headerWrapper('He said Hello to her', 2),
'##Hello': '##Hello',
'##Hello##': '##Hello##',
'He##llo': 'He##llo',
};
const headersLevel3 = {
'### Hello': headerWrapper('Hello', 3),
'### Rocket.Cat': headerWrapper('Rocket.Cat', 3),
'### Hi': headerWrapper('Hi', 3),
'### Hello this is dog': headerWrapper('Hello this is dog', 3),
'### Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 3),
'### He said Hello to her': headerWrapper('He said Hello to her', 3),
'###Hello': '###Hello',
'###Hello###': '###Hello###',
'He###llo': 'He###llo',
};
const headersLevel4 = {
'#### Hello': headerWrapper('Hello', 4),
'#### Rocket.Cat': headerWrapper('Rocket.Cat', 4),
'#### Hi': headerWrapper('Hi', 4),
'#### Hello this is dog': headerWrapper('Hello this is dog', 4),
'#### Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 4),
'#### He said Hello to her': headerWrapper('He said Hello to her', 4),
'####Hello': '####Hello',
'####Hello####': '####Hello####',
'He####llo': 'He####llo',
};
const quote = {
'&gt;Hello': escapeHTML('&gt;Hello'),
'&gt;Rocket.Cat': escapeHTML('&gt;Rocket.Cat'),
'&gt;Hi': escapeHTML('&gt;Hi'),
'&gt; Hello this is dog': escapeHTML('&gt; Hello this is dog'),
'&gt; Rocket cat says Hello': escapeHTML('&gt; Rocket cat says Hello'),
'&gt; He said Hello to her': escapeHTML('&gt; He said Hello to her'),
'&gt; He said Hello to her ': escapeHTML('&gt; He said Hello to her '),
'&lt;Hello': escapeHTML('&lt;Hello'),
'&lt;Rocket.Cat&gt;': escapeHTML('&lt;Rocket.Cat&gt;'),
' &gt;Hi': escapeHTML(' &gt;Hi'),
'Hello &gt; this is dog': escapeHTML('Hello &gt; this is dog'),
'Roc&gt;ket cat says Hello': escapeHTML('Roc&gt;ket cat says Hello'),
'He said Hello to her&gt;': escapeHTML('He said Hello to her&gt;'),
'>Hello': quoteWrapper('Hello'),
'>Rocket.Cat': quoteWrapper('Rocket.Cat'),
'>Hi': quoteWrapper('Hi'),
'> Hello this is dog': quoteWrapper(' Hello this is dog'),
'> Rocket cat says Hello': quoteWrapper(' Rocket cat says Hello'),
'> He said Hello to her': quoteWrapper(' He said Hello to her'),
'<Hello': escapeHTML('<Hello'),
'<Rocket.Cat>': escapeHTML('<Rocket.Cat>'),
' >Hi': escapeHTML(' >Hi'),
'Hello > this is dog': escapeHTML('Hello > this is dog'),
'Roc>ket cat says Hello': escapeHTML('Roc>ket cat says Hello'),
'He said Hello to her>': escapeHTML('He said Hello to her>'),
};
const link = {
'&lt;http://link|Text&gt;': escapeHTML('&lt;http://link|Text&gt;'),
'&lt;https://open.rocket.chat/|Open Site For Rocket.Chat&gt;': escapeHTML('&lt;https://open.rocket.chat/|Open Site For Rocket.Chat&gt;'),
'&lt;https://open.rocket.chat/ | Open Site For Rocket.Chat&gt;': escapeHTML(
'&lt;https://open.rocket.chat/ | Open Site For Rocket.Chat&gt;',
),
'&lt;https://rocket.chat/|Rocket.Chat Site&gt;': '&amp;lt;https://rocket.chat/|Rocket.Chat Site&amp;gt;',
'&lt;https://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site&gt;': escapeHTML(
'&lt;https://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site&gt;',
),
'&lt;http://linkText&gt;': escapeHTML('&lt;http://linkText&gt;'),
'&lt;https:open.rocket.chat/ | Open Site For Rocket.Chat&gt;': escapeHTML('&lt;https:open.rocket.chat/ | Open Site For Rocket.Chat&gt;'),
'https://open.rocket.chat/|Open Site For Rocket.Chat': escapeHTML('https://open.rocket.chat/|Open Site For Rocket.Chat'),
'&lt;www.open.rocket.chat/|Open Site For Rocket.Chat&gt;': escapeHTML('&lt;www.open.rocket.chat/|Open Site For Rocket.Chat&gt;'),
'&lt;htps://rocket.chat/|Rocket.Chat Site&gt;': escapeHTML('&lt;htps://rocket.chat/|Rocket.Chat Site&gt;'),
'&lt;ttps://rocket.chat/|Rocket.Chat Site&gt;': escapeHTML('&lt;ttps://rocket.chat/|Rocket.Chat Site&gt;'),
'&lt;tps://rocket.chat/|Rocket.Chat Site&gt;': escapeHTML('&lt;tps://rocket.chat/|Rocket.Chat Site&gt;'),
'&lt;open.rocket.chat/|Open Site For Rocket.Chat&gt;': escapeHTML('&lt;open.rocket.chat/|Open Site For Rocket.Chat&gt;'),
'&lt;htts://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site&gt;': escapeHTML(
'&lt;htts://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site&gt;',
),
'<http://invalid link|Text>': escapeHTML('<http://invalid link|Text>'),
'<http://link|Text>': linkWrapped('http://link', 'Text'),
'<https://open.rocket.chat/|Open Site For Rocket.Chat>': linkWrapped('https://open.rocket.chat/', 'Open Site For Rocket.Chat'),
'<https://open.rocket.chat/ | Open Site For Rocket.Chat>': linkWrapped(
encodeURI('https://open.rocket.chat/ '),
' Open Site For Rocket.Chat',
),
'<https://rocket.chat/|Rocket.Chat Site>': linkWrapped('https://rocket.chat/', 'Rocket.Chat Site'),
'<https://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site>': linkWrapped(
'https://rocket.chat/docs/developer-guides/testing/#testing',
'Testing Entry on Rocket.Chat Docs Site',
),
'<http://linkText>': escapeHTML('<http://linkText>'),
'<https:open.rocket.chat/ | Open Site For Rocket.Chat>': escapeHTML('<https:open.rocket.chat/ | Open Site For Rocket.Chat>'),
'<www.open.rocket.chat/|Open Site For Rocket.Chat>': escapeHTML('<www.open.rocket.chat/|Open Site For Rocket.Chat>'),
'<htps://rocket.chat/|Rocket.Chat Site>': escapeHTML('<htps://rocket.chat/|Rocket.Chat Site>'),
'<ttps://rocket.chat/|Rocket.Chat Site>': escapeHTML('<ttps://rocket.chat/|Rocket.Chat Site>'),
'<tps://rocket.chat/|Rocket.Chat Site>': escapeHTML('<tps://rocket.chat/|Rocket.Chat Site>'),
'<open.rocket.chat/|Open Site For Rocket.Chat>': escapeHTML('<open.rocket.chat/|Open Site For Rocket.Chat>'),
'<htts://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site>': escapeHTML(
'<htts://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site>',
),
'[Text](http://invalid link)': '[Text](http://invalid link)',
'[Text](http://link)': linkWrapped('http://link', 'Text'),
'[Open Site For Rocket.Chat](https://open.rocket.chat/)': linkWrapped('https://open.rocket.chat/', 'Open Site For Rocket.Chat'),
'[ Open Site For Rocket.Chat ](https://open.rocket.chat/)': linkWrapped('https://open.rocket.chat/', ' Open Site For Rocket.Chat '),
'[Rocket.Chat Site](https://rocket.chat/)': linkWrapped('https://rocket.chat/', 'Rocket.Chat Site'),
'[Testing Entry on Rocket.Chat Docs Site](https://rocket.chat/docs/developer-guides/testing/#testing)': linkWrapped(
'https://rocket.chat/docs/developer-guides/testing/#testing',
'Testing Entry on Rocket.Chat Docs Site',
),
'[](http://linkText)': '[](http://linkText)',
'[text]': '[text]',
'[Open Site For Rocket.Chat](https:open.rocket.chat/)': '[Open Site For Rocket.Chat](https:open.rocket.chat/)',
'[Open Site For Rocket.Chat](www.open.rocket.chat/)': '[Open Site For Rocket.Chat](www.open.rocket.chat/)',
'[Rocket.Chat Site](htps://rocket.chat/)': '[Rocket.Chat Site](htps://rocket.chat/)',
'[Rocket.Chat Site](ttps://rocket.chat/)': '[Rocket.Chat Site](ttps://rocket.chat/)',
'[Rocket.Chat Site](tps://rocket.chat/)': '[Rocket.Chat Site](tps://rocket.chat/)',
'[Open Site For Rocket.Chat](open.rocket.chat/)': '[Open Site For Rocket.Chat](open.rocket.chat/)',
'[Testing Entry on Rocket.Chat Docs Site](htts://rocket.chat/docs/developer-guides/testing/#testing)':
'[Testing Entry on Rocket.Chat Docs Site](htts://rocket.chat/docs/developer-guides/testing/#testing)',
'[Text](http://link?param1=1&param2=2)': linkWrapped('http://link?param1=1&amp;param2=2', 'Text'),
'[Testing Double parentheses](https://en.wikipedia.org/wiki/Disambiguation_(disambiguation))': linkWrapped(
'https://en.wikipedia.org/wiki/Disambiguation_(disambiguation)',
'Testing Double parentheses',
),
'[Testing data after Double parentheses](https://en.wikipedia.org/wiki/Disambiguation_(disambiguation)/blabla/bla)': linkWrapped(
'https://en.wikipedia.org/wiki/Disambiguation_(disambiguation)/blabla/bla',
'Testing data after Double parentheses',
),
};
Object.entries(link).forEach(([key, value]) => {
link[`before (test) ${key} after (test)`] = `before (test) ${value} after (test)`;
});
const inlinecode = {
'`code`': inlinecodeWrapper('code'),
'`code` begin': `${inlinecodeWrapper('code')} begin`,
'End `code`': `End ${inlinecodeWrapper('code')}`,
'Middle `code` middle': `Middle ${inlinecodeWrapper('code')} middle`,
'`code`begin': `${inlinecodeWrapper('code')}begin`,
'End`code`': `End${inlinecodeWrapper('code')}`,
'Middle`code`middle': `Middle${inlinecodeWrapper('code')}middle`,
};
const code = {
'```code```': codeWrapper('<span class="hljs-keyword">code</span>', 'clean'),
'```code': codeWrapper('<span class="hljs-keyword">code</span>\n', 'clean'),
'```code\n': codeWrapper('<span class="hljs-keyword">code</span>\n', 'clean'),
'```\ncode\n```': codeWrapper('<span class="hljs-keyword">code</span>\n', 'clean'),
'```code\n```': codeWrapper('<span class="hljs-keyword">code</span>\n', 'clean'),
'```\ncode```': codeWrapper('<span class="hljs-keyword">code</span>', 'clean'),
"```javascript\nvar a = 'log';\nconsole.log(a);```": codeWrapper(
'<span class="hljs-keyword">var</span> a = <span class="hljs-string">&#x27;log&#x27;</span>;\n<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(a);',
'javascript',
),
'```*code*```': codeWrapper('<span class="hljs-emphasis">*code*</span>', 'markdown'),
'```**code**```': codeWrapper('<span class="hljs-strong">**code**</span>', 'markdown'),
'```__code__```': codeWrapper('<span class="hljs-strong">__code__</span>', 'markdown'),
};
const nested = {
'> some quote\n`window.location.reload();`': `${quoteWrapper(' some quote')}${inlinecodeWrapper('window.location.reload();')}`,
};
/*
* Markdown Filters
*/
const boldFiltered = {
'*Hello*': 'Hello',
'**Hello**': 'Hello',
'*Hello**': 'Hello',
'He*llo': 'He*llo',
'*Hello': '*Hello',
'Hello*': 'Hello*',
'***Hello***': '***Hello***',
'***Hello**': '***Hello**',
'*Hello* there': 'Hello there',
'**Hello** there': 'Hello there',
'Hi, *Hello*': 'Hi, Hello',
'Hi, **Hello**': 'Hi, Hello',
'Hi, *Hello* how are you?': 'Hi, Hello how are you?',
'Hi, **Hello** how are you?': 'Hi, Hello how are you?',
};
const italicFiltered = {
'_Hello_': 'Hello',
'__Hello__': 'Hello',
'_Hello__': 'Hello',
'He_llo': 'He_llo',
'_Hello': '_Hello',
'__Hello': '__Hello',
'Hello_': 'Hello_',
'___Hello___': '___Hello___',
'___Hello__': '___Hello__',
'_Hello_ there': 'Hello there',
'__Hello__ there': 'Hello there',
'Hi, _Hello_': 'Hi, Hello',
'Hi, __Hello__': 'Hi, Hello',
'Hi, _Hello_ how are you?': 'Hi, Hello how are you?',
'Hi, __Hello__ how are you?': 'Hi, Hello how are you?',
};
const strikeFiltered = {
'~Hello~': 'Hello',
'~~Hello~~': 'Hello',
'~~Hello': '~~Hello',
'~Hello~~': 'Hello',
'He~llo': 'He~llo',
'~Hello': '~Hello',
'Hello~': 'Hello~',
'~~~Hello~~~': '~~~Hello~~~',
'~~~Hello~~': '~~~Hello~~',
'~Hello~ there': 'Hello there',
'~~Hello~~ there': 'Hello there',
'Hi, ~Hello~': 'Hi, Hello',
'Hi, ~~Hello~~': 'Hi, Hello',
'Hi, ~Hello~ how are you?': 'Hi, Hello how are you?',
'Hi, ~~Hello~~ how are you?': 'Hi, Hello how are you?',
};
const headingFiltered = {
'# Hello': 'Hello',
'## Hello': 'Hello',
'### Hello': 'Hello',
'#### Hello': 'Hello',
'#Hello': '#Hello',
'##Hello': '##Hello',
'###Hello': '###Hello',
'####Hello': '####Hello',
'He#llo': 'He#llo',
'# Hello there': 'Hello there',
'Hi, # Hello': 'Hi, # Hello',
'Hi, # Hello there': 'Hi, # Hello there',
};
const quoteFiltered = {
'>Hello': 'Hello',
'> Hello': ' Hello',
'>>>\nHello\n<<<': 'Hello',
'>>>\nHello there!\n<<<': 'Hello there!',
'>>>\n Hello there! \n<<<': ' Hello there! ',
};
const linkFiltered = {
'[Text](http://link)': 'Text',
'[Open Site For Rocket.Chat](https://open.rocket.chat/)': 'Open Site For Rocket.Chat',
'[ Open Site For Rocket.Chat](https://open.rocket.chat/ )': ' Open Site For Rocket.Chat',
'[Rocket.Chat Site](https://rocket.chat/)': 'Rocket.Chat Site',
'<http://link|Text>': 'Text',
'<http://link|Text for test>': 'Text for test',
};
const inlinecodeFiltered = {
'`code`': 'code',
'`code` begin': 'code begin',
'End `code`': 'End code',
'Middle `code` middle': 'Middle code middle',
'`code`begin': 'codebegin',
'End`code`': 'Endcode',
'Middle`code`middle': 'Middlecodemiddle',
};
const blockcodeFiltered = {
'```code```': 'code',
'```code': 'code',
'code```': 'code',
'Here ```code``` lies': 'Here code lies',
'Here```code```lies': 'Herecodelies',
};
const defaultObjectTest = (result, object, objectKey) => expect(result.html).to.be.equal(object[objectKey]);
const testObject = (object, parser = original, test = defaultObjectTest) => {
Object.keys(object).forEach((objectKey) => {
describe(objectKey, () => {
const message = parser === original ? { html: escapeHTML(objectKey) } : objectKey;
const result = parser === original ? Markdown.mountTokensBack(parser(message)) : { html: parser(message) };
it(`should be equal to ${object[objectKey]}`, () => {
test(result, object, objectKey);
});
});
});
};
describe('Original', () => {
describe('Bold', () => testObject(bold));
describe('Italic', () => testObject(italic));
describe('Strike', () => testObject(strike));
describe('Headers', () => {
describe('Level 1', () => testObject(headersLevel1));
describe('Level 2', () => testObject(headersLevel2));
describe('Level 3', () => testObject(headersLevel3));
describe('Level 4', () => testObject(headersLevel4));
});
describe('Quote', () => testObject(quote));
describe('Link', () => testObject(link));
describe('Inline Code', () => testObject(inlinecode));
describe('Code', () => testObject(code));
describe('Nested', () => testObject(nested));
});
describe('Filtered', () => {
describe('BoldFilter', () => testObject(boldFiltered, filtered));
describe('Italic', () => testObject(italicFiltered, filtered));
describe('StrikeFilter', () => testObject(strikeFiltered, filtered));
describe('HeadingFilter', () => testObject(headingFiltered, filtered));
describe('QuoteFilter', () => testObject(quoteFiltered, filtered));
describe('LinkFilter', () => testObject(linkFiltered, filtered));
describe('inlinecodeFilter', () => testObject(inlinecodeFiltered, filtered));
describe('blockcodeFilter', () => testObject(blockcodeFiltered, filtered));
});
// describe('Marked', function() {
// describe('Bold', () => testObject(bold, marked));
// describe('Italic', () => testObject(italic, marked));
// describe('Strike', () => testObject(strike, marked));
// describe('Headers', () => {
// describe('Level 1', () => testObject(headersLevel1, marked));
// describe('Level 2', () => testObject(headersLevel2, marked));
// describe('Level 3', () => testObject(headersLevel3, marked));
// describe('Level 4', () => testObject(headersLevel4, marked));
// });
// describe('Quote', () => testObject(quote, marked));
// });

View File

@@ -0,0 +1,212 @@
import { expect } from 'chai';
import hljs, { register } from '../../../../app/markdown/lib/hljs';
const lazyLanguages: string[] = [
'onec',
'abnf',
'accesslog',
'actionscript',
'ada',
'apache',
'applescript',
'arduino',
'armasm',
'asciidoc',
'aspectj',
'autohotkey',
'autoit',
'avrasm',
'awk',
'axapta',
'bash',
'basic',
'bnf',
'brainfuck',
'cal',
'capnproto',
'ceylon',
'clean',
'clojure',
'clojure-repl',
'cmake',
'coffeescript',
'coq',
'cos',
'cpp',
'crmsh',
'crystal',
'csp',
'css',
'd',
'dart',
'delphi',
'diff',
'django',
'dns',
'dockerfile',
'dos',
'dsconfig',
'dts',
'dust',
'ebnf',
'elixir',
'elm',
'erb',
'erlang',
'excel',
'fix',
'flix',
'fortran',
'fsharp',
'gams',
'gauss',
'gcode',
'gherkin',
'glsl',
'go',
'golo',
'gradle',
'groovy',
'haml',
'handlebars',
'haskell',
'haxe',
'hsp',
'http',
'hy',
'inform7',
'ini',
'irpf90',
'java',
'javascript',
'jboss-cli',
'json',
'julia',
'julia-repl',
'kotlin',
'lasso',
'ldif',
'leaf',
'less',
'lisp',
'livecodeserver',
'livescript',
'llvm',
'lsl',
'lua',
'makefile',
'markdown',
'mathematica',
'matlab',
'maxima',
'mel',
'mercury',
'mipsasm',
'mizar',
'perl',
'mojolicious',
'monkey',
'moonscript',
'n1ql',
'nginx',
'nix',
'nsis',
'objectivec',
'ocaml',
'openscad',
'oxygene',
'parser3',
'pf',
'php',
'pony',
'powershell',
'processing',
'profile',
'prolog',
'protobuf',
'puppet',
'purebasic',
'python',
'q',
'qml',
'r',
'rib',
'roboconf',
'rsl',
'ruleslanguage',
'rust',
'scala',
'scheme',
'scilab',
'scss',
'shell',
'smali',
'smalltalk',
'sml',
'sqf',
'sql',
'stan',
'stata',
'step21',
'stylus',
'subunit',
'swift',
'taggerscript',
'yaml',
'tap',
'tcl',
'thrift',
'tp',
'twig',
'typescript',
'vala',
'vbnet',
'vbscript',
'verilog',
'vhdl',
'vim',
'x86asm',
'xl',
'xquery',
'zephir',
];
const eagerLanguages = ['markdown', 'clean', 'javascript'];
describe('hljs', () => {
describe('eagerly registered languages', () => {
eagerLanguages.forEach((lang) => {
it(`should register '${lang}' when the module is loaded`, () => {
expect(hljs.getLanguage(lang)).to.be.an('object');
});
});
});
describe('register', () => {
afterEach(() => {
hljs
.listLanguages()
.filter((lang) => !eagerLanguages.includes(lang))
.forEach((lang) => hljs.unregisterLanguage(lang));
});
lazyLanguages.forEach((lang) => {
it(`should register a syntax definition for '${lang}'`, async () => {
hljs.unregisterLanguage(lang);
await register(lang);
expect(hljs.listLanguages()).to.include(lang);
});
});
['', 'not-a-language'].forEach((lang) => {
it(`should fall back to plaintext for '${lang}'`, async () => {
await register(lang);
expect(hljs.listLanguages()).to.include('plaintext');
});
});
});
});

View File

@@ -0,0 +1,43 @@
import proxyquire from 'proxyquire';
export type MarkdownToken = { token: string; type?: string; text: string; noHtml?: string };
export type ParsedMessage = { html?: string; msg?: string; tokens?: MarkdownToken[] };
export type MarkdownOptions = { supportSchemesForLink?: string; headers?: boolean; rootUrl?: string };
const mocks = {
'meteor/meteor': {
Meteor: {
absoluteUrl() {
return 'http://localhost:3000/';
},
},
},
};
export const { Markdown, filterMarkdown, createMarkdownMessageRenderer, createMarkdownNotificationRenderer } = proxyquire
.noCallThru()
.load('../../../../app/markdown/lib/markdown', mocks) as typeof import('../../../../app/markdown/lib/markdown');
export const { original } = proxyquire.noCallThru().load('../../../../app/markdown/lib/parser/original/original', mocks) as {
original: (message: ParsedMessage, options?: MarkdownOptions) => ParsedMessage;
};
export const { code } = proxyquire
.noCallThru()
.load('../../../../app/markdown/lib/parser/original/code', mocks) as typeof import('../../../../app/markdown/lib/parser/original/code');
const copyonly = (text: string, marker: string) => `<span class="copyonly">${marker}</span>${text}<span class="copyonly">${marker}</span>`;
export const markup = {
bold: (text: string) => copyonly(`<strong>${text}</strong>`, '*'),
inlineCode: (text: string) => copyonly(`<span><code class="code-colors inline">${text}</code></span>`, '`'),
anchor: (url: string, title: string, target = '_blank') =>
`<a data-title="${url}" href="${url}" target="${target}" rel="noopener noreferrer">${title}</a>`,
image: (url: string, title: string, target = '_blank') =>
`<a data-title="${url}" href="${url}" title="${title}" target="${target}" rel="noopener noreferrer">` +
`<div class="inline-image" style="background-image: url(${url});"></div></a>`,
blockquote: (text: string, marker = '&gt;') =>
`<blockquote class="background-transparent-darker-before"><span class="copyonly">${marker}</span>${text}</blockquote>`,
};

View File

@@ -0,0 +1,235 @@
import { escapeHTML } from '@rocket.chat/tools';
import { expect } from 'chai';
import sinon from 'sinon';
import type { MarkdownOptions, ParsedMessage } from './markdown.mocks';
import {
Markdown,
original,
code,
filterMarkdown,
createMarkdownMessageRenderer,
createMarkdownNotificationRenderer,
markup,
} from './markdown.mocks';
import hljs from '../../../../app/markdown/lib/hljs';
const { bold, inlineCode, anchor, image, blockquote } = markup;
const rootUrl = 'http://localhost:3000/';
const defaultOptions = { supportSchemesForLink: 'http,https', headers: true, rootUrl };
const render = (text: string, options: MarkdownOptions = defaultOptions) =>
Markdown.mountTokensBack(original({ html: text }, options)).html;
const renderEscaped = (text: string, options: MarkdownOptions = defaultOptions) => render(escapeHTML(text), options);
describe('Markdown entry points', () => {
it('should escape HTML before parsing', () => {
expect(Markdown.parse('<img src=x onerror=alert(1)> *Hello*')).to.equal(`&lt;img src=x onerror=alert(1)&gt; ${bold('Hello')}`);
});
it('should keep HTML when parsing unescaped input', () => {
expect(Markdown.parseNotEscaped('<b>x</b> *Hello*')).to.equal(`<b>x</b> ${bold('Hello')}`);
});
it('should leave the tokens unmounted when parsing a message', () => {
const message = Markdown.parseMessageNotEscaped({ html: '`code`' });
expect(message.tokens).to.have.lengthOf(1);
expect(message.tokens?.[0].type).to.equal('inlinecode');
expect(message.html).to.equal(message.tokens?.[0].token);
});
it('should expose the code parser', () => {
const message = Markdown.code({ html: '`code`' });
expect(Markdown.mountTokensBack(message).html).to.equal(inlineCode('code'));
});
it('should strip the markdown syntax when filtering', () => {
expect(Markdown.filterMarkdownFromMessage('*Hello* `there`')).to.equal('Hello there');
expect(filterMarkdown('*Hello* `there`')).to.equal('Hello there');
});
});
describe('Markdown token restoration', () => {
it('should return the message untouched when it has no tokens', () => {
const message = { html: 'plain text' };
expect(Markdown.mountTokensBack(message)).to.equal(message);
expect(message.html).to.equal('plain text');
});
it('should restore the plain text representation when useHtml is false', () => {
const message = Markdown.parseMessageNotEscaped({ html: 'see `code`' });
expect(Markdown.mountTokensBack(message, false).html).to.equal('see `code`');
});
it('should restore tokens that are only revealed by another token', () => {
const message = {
html: 'start =!=outer=!=',
tokens: [
{ token: '=!=inner=!=', text: '<em>inner</em>' },
{ token: '=!=outer=!=', text: '<b>=!=inner=!=</b>' },
],
};
expect(Markdown.mountTokensBack(message).html).to.equal('start <b><em>inner</em></b>');
});
it('should leave tokens that never appear in the message', () => {
const message = {
html: 'start =!=present=!=',
tokens: [
{ token: '=!=absent=!=', text: '<em>absent</em>' },
{ token: '=!=present=!=', text: '<b>present</b>' },
],
};
expect(Markdown.mountTokensBack(message).html).to.equal('start <b>present</b>');
});
it('should not treat $ sequences in the token text as replacement patterns', () => {
const message = { html: '=!=t=!=', tokens: [{ token: '=!=t=!=', text: '$& $1 $$' }] };
expect(Markdown.mountTokensBack(message).html).to.equal('$& $1 $$');
});
});
describe('Markdown renderers', () => {
const renderMessage = createMarkdownMessageRenderer({
rootUrl: 'http://localhost:3000/',
supportSchemesForLink: 'http,https',
headers: true,
});
it('should render markdown of a non-empty message', () => {
expect(renderMessage({ html: '# Hello' })?.html).to.equal('<h1>Hello</h1>');
});
const emptyMessages: (ParsedMessage | undefined)[] = [undefined, { html: '' }, { html: ' \n\t ' }];
emptyMessages.forEach((message, index) => {
it(`should return the message as-is when there is nothing to render (case ${index})`, () => {
expect(renderMessage(message)).to.equal(message);
});
});
it('should strip the markdown syntax when rendering a notification', () => {
expect(createMarkdownNotificationRenderer()('*Hello* there')).to.equal('Hello there');
});
});
describe('Original parser', () => {
it('should convert every new line to a <br>', () => {
expect(render('a\nb\nc')).to.equal('a<br>b<br>c');
});
it('should not render headings when the option is disabled', () => {
expect(render('# Hello', { ...defaultOptions, headers: false })).to.equal('# Hello');
});
describe('blockquotes', () => {
it('should render consecutive single-line blockquotes without a line break in between', () => {
expect(renderEscaped('>a\n>b')).to.equal(`${blockquote('a')}${blockquote('b')}`);
});
it('should render a multiline blockquote delimited by >>> and <<<', () => {
expect(renderEscaped('>>>\nline1\nline2\n<<<')).to.equal(
blockquote('line1<br>line2<span class="copyonly">&lt;&lt;&lt;</span>', '&gt;&gt;&gt;'),
);
});
});
describe('links', () => {
it('should render an internal link without a target', () => {
expect(render(`[Home](${rootUrl}channel/general)`)).to.equal(anchor(`${rootUrl}channel/general`, 'Home', ''));
expect(render(`<${rootUrl}channel/general|Home>`)).to.equal(anchor(`${rootUrl}channel/general`, 'Home', ''));
});
it('should not render a link whose URL is not parseable', () => {
expect(render('[Text](http://[)')).to.equal('[Text](http://[)');
expect(render('<http://[|Text>')).to.equal('<http://[|Text>');
});
it('should not render a link whose URL contains another token', () => {
expect(render('[Text](http://example.com/`code`)')).to.equal(`[Text](http://example.com/${inlineCode('code')})`);
});
it('should not render a link whose title holds a disallowed token', () => {
expect(render('[`code`](http://example.com/)')).to.equal(`[${inlineCode('code')}](http://example.com/)`);
expect(render('<http://example.com/|`code`>')).to.equal(`<http://example.com/|${inlineCode('code')}>`);
});
it('should render a link whose title only looks like a token', () => {
expect(render('[=!=abcdefghijklmnopq=!=](http://example.com/)')).to.equal(anchor('http://example.com/', '=!=abcdefghijklmnopq=!='));
});
it('should only render the schemes it was configured with', () => {
expect(render('[Text](ftp://example.com/)', { ...defaultOptions, supportSchemesForLink: 'ftp' })).to.equal(
anchor('ftp://example.com/', 'Text'),
);
expect(render('[Text](http://example.com/)', { ...defaultOptions, supportSchemesForLink: 'ftp' })).to.equal(
'[Text](http://example.com/)',
);
});
});
describe('images', () => {
it('should render an external image with target _blank', () => {
expect(render('![alt](http://example.com/a.png)')).to.equal(image('http://example.com/a.png', 'alt'));
});
it('should render an internal image without a target', () => {
expect(render(`![alt](${rootUrl}a.png)`)).to.equal(image(`${rootUrl}a.png`, 'alt', ''));
});
it('should not render an image whose URL is not parseable', () => {
expect(render('![alt](http://[)')).to.equal('![alt](http://[)');
});
it('should not render an image whose title holds a disallowed token', () => {
expect(render('![`code`](http://example.com/a.png)')).to.equal(`![${inlineCode('code')}](http://example.com/a.png)`);
});
});
});
describe('Code parser', () => {
it('should leave a message without code untouched', () => {
expect(code({ html: 'no code here' }).tokens).to.be.undefined;
expect(code({}).html).to.be.undefined;
});
it('should close an unclosed code block in both html and msg', () => {
const message: ParsedMessage = { html: '```code', msg: '```code' };
code(message);
expect(message.msg).to.equal('```code\n```');
expect(message.html).to.equal(message.tokens?.[0].token);
});
it('should restore the original fenced block when tokens are mounted back as text', () => {
const message = original({ html: '```\ncode\n```' }, defaultOptions);
expect(Markdown.mountTokensBack(message, false).html).to.equal('```\ncode\n```');
});
it('should fall back to automatic highlighting when highlighting the explicit language fails', () => {
const consoleError = sinon.stub(console, 'error');
const highlight = sinon.stub(hljs, 'highlight').throws(new Error('highlighting failed'));
const highlightAuto = sinon.spy(hljs, 'highlightAuto');
try {
const message = code({ html: '```javascript\nvar a;\n```' });
expect(highlight.args).to.deep.equal([['javascript', 'var a;\n']]);
expect(highlightAuto.calledOnce).to.be.true;
expect(message.tokens?.[0].text).to.contain(`code-colors hljs ${highlightAuto.firstCall.returnValue.language}'`);
expect(consoleError.calledOnce).to.be.true;
} finally {
sinon.restore();
}
});
});

View File

@@ -0,0 +1,314 @@
import { expect } from 'chai';
import { MentionsParser } from '../../../../app/mentions/lib/MentionsParser';
let mentionsParser;
beforeEach(() => {
mentionsParser = new MentionsParser({
pattern: () => '[0-9a-zA-Z-_.]+',
me: () => 'me',
});
});
describe('Mention', () => {
describe('getUserMentions', () => {
describe('for simple text, no mentions', () => {
const result = [];
['#rocket.cat', 'hello rocket.cat how are you?'].forEach((text) => {
it(`should return "${JSON.stringify(result)}" from "${text}"`, () => {
expect(result).to.be.deep.equal(mentionsParser.getUserMentions(text));
});
});
});
describe('for one user', () => {
const result = ['@rocket.cat'];
[
'@rocket.cat',
' @rocket.cat ',
'hello @rocket.cat',
// 'hello,@rocket.cat', // this test case is ignored since is not compatible with the message box behavior
'@rocket.cat, hello',
'@rocket.cat,hello',
'hello @rocket.cat how are you?',
].forEach((text) => {
it(`should return "${JSON.stringify(result)}" from "${text}"`, () => {
expect(result).to.be.deep.equal(mentionsParser.getUserMentions(text));
});
});
it.skip('should return without the "." from "@rocket.cat."', () => {
expect(result).to.be.deep.equal(mentionsParser.getUserMentions('@rocket.cat.'));
});
it.skip('should return without the "_" from "@rocket.cat_"', () => {
expect(result).to.be.deep.equal(mentionsParser.getUserMentions('@rocket.cat_'));
});
it.skip('should return without the "-" from "@rocket.cat-"', () => {
expect(result).to.be.deep.equal(mentionsParser.getUserMentions('@rocket.cat-'));
});
});
describe('for two users', () => {
const result = ['@rocket.cat', '@all'];
[
'@rocket.cat @all',
' @rocket.cat @all ',
'hello @rocket.cat and @all',
'@rocket.cat, hello @all',
'hello @rocket.cat and @all how are you?',
].forEach((text) => {
it(`should return "${JSON.stringify(result)}" from "${text}"`, () => {
expect(result).to.be.deep.equal(mentionsParser.getUserMentions(text));
});
});
});
});
describe('getChannelMentions', () => {
describe('for simple text, no mentions', () => {
const result = [];
['@rocket.cat', 'hello rocket.cat how are you?'].forEach((text) => {
it(`should return "${JSON.stringify(result)}" from "${text}"`, () => {
expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text));
});
});
});
describe('for one channel', () => {
const result = ['#general'];
['#general', ' #general ', 'hello #general', '#general, hello', 'hello #general, how are you?'].forEach((text) => {
it(`should return "${JSON.stringify(result)}" from "${text}"`, () => {
expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text));
});
});
it.skip('should return without the "." from "#general."', () => {
expect(result).to.be.deep.equal(mentionsParser.getUserMentions('#general.'));
});
it.skip('should return without the "_" from "#general_"', () => {
expect(result).to.be.deep.equal(mentionsParser.getUserMentions('#general_'));
});
it.skip('should return without the "-" from "#general."', () => {
expect(result).to.be.deep.equal(mentionsParser.getUserMentions('#general-'));
});
});
describe('for two channels', () => {
const result = ['#general', '#other'];
[
'#general #other',
' #general #other',
'hello #general and #other',
'#general, hello #other',
'hello #general #other, how are you?',
].forEach((text) => {
it(`should return "${JSON.stringify(result)}" from "${text}"`, () => {
expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text));
});
});
});
describe('for url with fragments', () => {
const result = [];
['http://localhost/#general'].forEach((text) => {
it(`should return nothing from "${text}"`, () => {
expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text));
});
});
});
describe('for messages with url and channels', () => {
const result = ['#general'];
['http://localhost/#general #general'].forEach((text) => {
it(`should return "${JSON.stringify(result)}" from "${text}"`, () => {
expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text));
});
});
});
});
});
const message = {
mentions: [
{ username: 'rocket.cat', name: 'Rocket.Cat' },
{ username: 'admin', name: 'Admin' },
{ username: 'me', name: 'Me' },
{ username: 'specialchars', name: '<img onerror=alert(hello)>' },
],
channels: [
{ name: 'general', _id: '42' },
{ name: 'rocket.cat', _id: '169' },
],
};
describe('replace methods', () => {
describe('replaceUsers', () => {
it('should render for @all', () => {
const result = mentionsParser.replaceUsers('@all', message, 'me');
expect(result).to.be.equal('<a class="mention-link mention-link--all mention-link--group" data-group="all">all</a>');
});
const str2 = 'rocket.cat';
it(`should render for "@${str2}"`, () => {
const result = mentionsParser.replaceUsers(`@${str2}`, message, 'me');
expect(result).to.be.equal(`<a class="mention-link mention-link--user" data-username="${str2}" title="${str2}">${str2}</a>`);
});
it(`should render for "hello ${str2}"`, () => {
const result = mentionsParser.replaceUsers(`hello @${str2}`, message, 'me');
expect(result).to.be.equal(`hello <a class="mention-link mention-link--user" data-username="${str2}" title="${str2}">${str2}</a>`);
});
it('should render for unknow/private user "hello @unknow"', () => {
const result = mentionsParser.replaceUsers('hello @unknow', message, 'me');
expect(result).to.be.equal('hello @unknow');
});
it('should render for me', () => {
const result = mentionsParser.replaceUsers('hello @me', message, 'me');
expect(result).to.be.equal('hello <a class="mention-link mention-link--me mention-link--user" data-username="me" title="me">me</a>');
});
});
describe('replaceUsers (RealNames)', () => {
beforeEach(() => {
mentionsParser.useRealName = () => true;
});
it('should render for @all', () => {
const result = mentionsParser.replaceUsers('@all', message, 'me');
expect(result).to.be.equal('<a class="mention-link mention-link--all mention-link--group" data-group="all">all</a>');
});
const str2 = 'rocket.cat';
const str2Name = 'Rocket.Cat';
it(`should render for "@${str2}"`, () => {
const result = mentionsParser.replaceUsers(`@${str2}`, message, 'me');
expect(result).to.be.equal(`<a class="mention-link mention-link--user" data-username="${str2}" title="${str2}">${str2Name}</a>`);
});
it(`should render for "hello @${str2}"`, () => {
const result = mentionsParser.replaceUsers(`hello @${str2}`, message, 'me');
expect(result).to.be.equal(
`hello <a class="mention-link mention-link--user" data-username="${str2}" title="${str2}">${str2Name}</a>`,
);
});
const specialchars = 'specialchars';
const specialcharsName = '&lt;img onerror=alert(hello)&gt;';
it(`should escape special characters in "hello @${specialchars}"`, () => {
const result = mentionsParser.replaceUsers(`hello @${specialchars}`, message, 'me');
expect(result).to.be.equal(
`hello <a class="mention-link mention-link--user" data-username="${specialchars}" title="${specialchars}">${specialcharsName}</a>`,
);
});
it(`should render for "hello<br>@${str2} <br>"`, () => {
const result = mentionsParser.replaceUsers(`hello<br>@${str2} <br>`, message, 'me');
expect(result).to.be.equal(
`hello<br><a class="mention-link mention-link--user" data-username="${str2}" title="${str2}">${str2Name}</a> <br>`,
);
});
it('should render for unknow/private user "hello @unknow"', () => {
const result = mentionsParser.replaceUsers('hello @unknow', message, 'me');
expect(result).to.be.equal('hello @unknow');
});
it('should render for me', () => {
const result = mentionsParser.replaceUsers('hello @me', message, 'me');
expect(result).to.be.equal('hello <a class="mention-link mention-link--me mention-link--user" data-username="me" title="me">Me</a>');
});
});
describe('replaceChannels', () => {
it('should render for #general', () => {
const result = mentionsParser.replaceChannels('#general', message);
expect('<).to.be.equal(class="mention-link mention-link--room" data-channel="42">#general</a>', result);
});
const str2 = '#rocket.cat';
it(`should render for ${str2}`, () => {
const result = mentionsParser.replaceChannels(str2, message);
expect(result).to.be.equal(`<a class="mention-link mention-link--room" data-channel="169">${str2}</a>`);
});
it(`should render for "hello ${str2}"`, () => {
const result = mentionsParser.replaceChannels(`hello ${str2}`, message);
expect(result).to.be.equal(`hello <a class="mention-link mention-link--room" data-channel="169">${str2}</a>`);
});
it('should render for unknow/private channel "hello #unknow"', () => {
const result = mentionsParser.replaceChannels('hello #unknow', message);
expect(result).to.be.equal('hello #unknow');
});
});
describe('parse all', () => {
it('should render for #general', () => {
message.html = '#general';
const result = mentionsParser.parse(message, 'me');
expect(result.html).to.be.equal('<a class="mention-link mention-link--room" data-channel="42">#general</a>');
});
it('should render for "#general and @rocket.cat', () => {
message.html = '#general and @rocket.cat';
const result = mentionsParser.parse(message, 'me');
expect(result.html).to.be.equal(
'<a class="mention-link mention-link--room" data-channel="42">#general</a> and <a class="mention-link mention-link--user" data-username="rocket.cat" title="rocket.cat">rocket.cat</a>',
);
});
it('should render for "', () => {
message.html = '';
const result = mentionsParser.parse(message, 'me');
expect(result.html).to.be.equal('');
});
it('should render for "simple text', () => {
message.html = 'simple text';
const result = mentionsParser.parse(message, 'me');
expect(result.html).to.be.equal('simple text');
});
});
describe('parse all (RealNames)', () => {
beforeEach(() => {
mentionsParser.useRealName = () => true;
});
it('should render for #general', () => {
message.html = '#general';
const result = mentionsParser.parse(message, 'me');
expect(result.html).to.be.equal('<a class="mention-link mention-link--room" data-channel="42">#general</a>');
});
it('should render for "#general and @rocket.cat', () => {
message.html = '#general and @rocket.cat';
const result = mentionsParser.parse(message, 'me');
expect(result.html).to.be.equal(
'<a class="mention-link mention-link--room" data-channel="42">#general</a> and <a class="mention-link mention-link--user" data-username="rocket.cat" title="rocket.cat">Rocket.Cat</a>',
);
});
it('should render for "', () => {
message.html = '';
const result = mentionsParser.parse(message, 'me');
expect(result.html).to.be.equal('');
});
it('should render for "simple text', () => {
message.html = 'simple text';
const result = mentionsParser.parse(message, 'me');
expect(result.html).to.be.equal('simple text');
});
});
});

View File

@@ -0,0 +1,162 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import { ltrim, rtrim } from '../../../../../lib/utils/stringUtils';
const { _getURL } = proxyquire.noCallThru().load('../../../../../app/utils/lib/getURL', {
'meteor/meteor': {
'Meteor': {
absoluteUrl() {
return 'http://localhost:3000/';
},
},
'@global': true,
},
});
const testPaths = (o, _processPath) => {
let processPath = _processPath;
if (o._root_url_path_prefix !== '') {
processPath = (path) => _processPath(o._root_url_path_prefix + path);
}
const cloudDeepLinkUrl = 'https://go.rocket.chat';
expect(_getURL('', o, cloudDeepLinkUrl)).to.be.equal(processPath(''));
expect(_getURL('/', o, cloudDeepLinkUrl)).to.be.equal(processPath(''));
expect(_getURL('//', o, cloudDeepLinkUrl)).to.be.equal(processPath(''));
expect(_getURL('///', o, cloudDeepLinkUrl)).to.be.equal(processPath(''));
expect(_getURL('/channel', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel'));
expect(_getURL('/channel/', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel'));
expect(_getURL('/channel//', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel'));
expect(_getURL('/channel/123', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel/123'));
expect(_getURL('/channel/123/', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel/123'));
expect(_getURL('/channel/123?id=456&name=test', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel/123?id=456&name=test'));
expect(_getURL('/channel/123/?id=456&name=test', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel/123?id=456&name=test'));
};
const getCloudUrl = (_site_url, path) => {
path = ltrim(path, '/');
const url = `https://go.rocket.chat/?host=${encodeURIComponent(_site_url.replace(/https?:\/\//, ''))}&path=${encodeURIComponent(path)}`;
if (_site_url.includes('http://')) {
return `${url}&secure=no`;
}
return url;
};
const testCases = (options) => {
const _site_url = rtrim(options._site_url, '/');
if (!options.cloud) {
if (options._cdn_prefix === '') {
if (options.full && !options.cdn) {
it('should return with host if full: true', () => {
testPaths(options, (path) => _site_url + path);
});
}
if (!options.full && options.cdn) {
it('should return without cdn prefix if cdn: true', () => {
testPaths(options, (path) => path);
});
}
if (!options.full && !options.cdn) {
it('should return without host by default', () => {
testPaths(options, (path) => path);
});
}
if (options.full && options.cdn) {
it('should return with host if full: true and cdn: true', () => {
testPaths(options, (path) => _site_url + path);
});
}
} else {
if (options.full && !options.cdn) {
it('should return with host if full: true', () => {
testPaths(options, (path) => _site_url + path);
});
}
if (!options.full && options.cdn) {
it('should return with cdn prefix if cdn: true', () => {
testPaths(options, (path) => options._cdn_prefix + path);
});
}
if (!options.full && !options.cdn) {
it('should return without host by default', () => {
testPaths(options, (path) => path);
});
}
if (options.full && options.cdn) {
it('should return with cdn prefix if full: true and cdn: true', () => {
testPaths(options, (path) => options._cdn_prefix + path);
});
}
}
} else if (options._cdn_prefix === '') {
if (!options.full && options.cdn) {
it('should return with cloud host if cdn: true', () => {
testPaths(options, (path) => getCloudUrl(_site_url, path));
});
}
if (!options.full && !options.cdn) {
it('should return with cloud host if full: fase and cdn: false', () => {
testPaths(options, (path) => getCloudUrl(_site_url, path));
});
}
} else if (!options.full && !options.cdn) {
it('should return with cloud host if full: fase and cdn: false', () => {
testPaths(options, (path) => getCloudUrl(_site_url, path));
});
}
};
const testCasesForOptions = (description, options) => {
describe(description, () => {
testCases({ ...options, cdn: false, full: false, cloud: false });
testCases({ ...options, cdn: true, full: false, cloud: false });
testCases({ ...options, cdn: false, full: true, cloud: false });
testCases({ ...options, cdn: false, full: false, cloud: true });
testCases({ ...options, cdn: true, full: true, cloud: false });
testCases({ ...options, cdn: false, full: true, cloud: true });
testCases({ ...options, cdn: true, full: false, cloud: true });
testCases({ ...options, cdn: true, full: true, cloud: true });
});
};
describe('getURL', () => {
testCasesForOptions('getURL with no CDN, no PREFIX for http://localhost:3000/', {
_cdn_prefix: '',
_root_url_path_prefix: '',
_site_url: 'http://localhost:3000/',
});
testCasesForOptions('getURL with no CDN, no PREFIX for http://localhost:3000', {
_cdn_prefix: '',
_root_url_path_prefix: '',
_site_url: 'http://localhost:3000',
});
testCasesForOptions('getURL with CDN, no PREFIX for http://localhost:3000/', {
_cdn_prefix: 'https://cdn.com',
_root_url_path_prefix: '',
_site_url: 'http://localhost:3000/',
});
testCasesForOptions('getURL with CDN, PREFIX for http://localhost:3000/', {
_cdn_prefix: 'https://cdn.com',
_root_url_path_prefix: 'sub',
_site_url: 'http://localhost:3000/',
});
testCasesForOptions('getURL with CDN, PREFIX for https://localhost:3000/', {
_cdn_prefix: 'https://cdn.com',
_root_url_path_prefix: 'sub',
_site_url: 'https://localhost:3000/',
});
});

View File

@@ -0,0 +1,49 @@
import { expect } from 'chai';
import { fileUploadIsValidContentTypeFromSettings } from '../../../../../app/utils/lib/restrictions';
describe('fileUploadIsValidContentTypeFromSettings', () => {
it('should return true if type is not defined and whiteList is not defined', () => {
expect(fileUploadIsValidContentTypeFromSettings(undefined, '', '')).to.be.true;
});
it('should return false if type is not defined and whiteList is defined', () => {
expect(fileUploadIsValidContentTypeFromSettings(undefined, 'image/jpeg', '')).to.be.false;
});
it('should return true if type is defined and whiteList is not defined', () => {
expect(fileUploadIsValidContentTypeFromSettings('image/jpeg', '', '')).to.be.true;
});
it('should return true if type is defined and whiteList is defined and type is in whiteList', () => {
expect(fileUploadIsValidContentTypeFromSettings('image/jpeg', 'image/jpeg', '')).to.be.true;
});
it('should return false if type is defined and whiteList is defined and type is not in whiteList', () => {
expect(fileUploadIsValidContentTypeFromSettings('image/png', 'image/jpeg', '')).to.be.false;
});
it('should return false if type is defined and whiteList is not defined and type is in blackList', () => {
expect(fileUploadIsValidContentTypeFromSettings('image/jpeg', '', 'image/jpeg')).to.be.false;
});
it('should return true if type is defined and whiteList is not defined and type is not in blackList', () => {
expect(fileUploadIsValidContentTypeFromSettings('image/png', '', 'image/jpeg')).to.be.true;
});
it('should return true if type is defined and whiteList is defined and type is in whiteList with wildcard', () => {
expect(fileUploadIsValidContentTypeFromSettings('image/jpeg', 'image/*', '')).to.be.true;
});
it('should return false if type is defined and whiteList is defined and type is not in whiteList with wildcard', () => {
expect(fileUploadIsValidContentTypeFromSettings('text/plain', 'image/*', '')).to.be.false;
});
it('should return false if type is defined and whiteList is not defined and type is in blackList with wildcard', () => {
expect(fileUploadIsValidContentTypeFromSettings('image/jpeg', '', 'image/*')).to.be.false;
});
it('should return true if type is defined and whiteList is defined and type is not in blackList with wildcard', () => {
expect(fileUploadIsValidContentTypeFromSettings('text/plain', '', 'image/*')).to.be.true;
});
});

View File

@@ -0,0 +1,41 @@
import { isFederationAddServerProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('FederationAddServerProps (definition/rest/v1)', () => {
describe('isFederationAddServerProps', () => {
it('should be a function', () => {
assert.isFunction(isFederationAddServerProps);
});
it('should return false when provided anything that is not an FederationAddServerProps', () => {
assert.isFalse(isFederationAddServerProps(undefined));
assert.isFalse(isFederationAddServerProps(null));
assert.isFalse(isFederationAddServerProps(''));
assert.isFalse(isFederationAddServerProps(123));
assert.isFalse(isFederationAddServerProps({}));
assert.isFalse(isFederationAddServerProps([]));
assert.isFalse(isFederationAddServerProps(new Date()));
assert.isFalse(isFederationAddServerProps(new Error()));
});
it('should return false if serverName is not provided to FederationAddServerProps', () => {
assert.isFalse(isFederationAddServerProps({}));
});
it('should accept a serverName with nothing else', () => {
assert.isTrue(
isFederationAddServerProps({
serverName: 'serverName',
}),
);
});
it('should return false when extra parameters are provided to FederationAddServerProps', () => {
assert.isFalse(
isFederationAddServerProps({
serverName: 'serverName',
extra: 'extra',
}),
);
});
});
});

View File

@@ -0,0 +1,49 @@
import { isFederationJoinExternalPublicRoomProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('FederationJoinExternalPublicRoomProps (definition/rest/v1)', () => {
describe('isFederationJoinExternalPublicRoomProps', () => {
it('should be a function', () => {
assert.isFunction(isFederationJoinExternalPublicRoomProps);
});
it('should return false when provided anything that is not an FederationJoinExternalPublicRoomProps', () => {
assert.isFalse(isFederationJoinExternalPublicRoomProps(undefined));
assert.isFalse(isFederationJoinExternalPublicRoomProps(null));
assert.isFalse(isFederationJoinExternalPublicRoomProps(''));
assert.isFalse(isFederationJoinExternalPublicRoomProps(123));
assert.isFalse(isFederationJoinExternalPublicRoomProps({}));
assert.isFalse(isFederationJoinExternalPublicRoomProps([]));
assert.isFalse(isFederationJoinExternalPublicRoomProps(new Date()));
assert.isFalse(isFederationJoinExternalPublicRoomProps(new Error()));
});
it('should return false if externalRoomId is not provided to FederationJoinExternalPublicRoomProps', () => {
assert.isFalse(isFederationJoinExternalPublicRoomProps({}));
});
it('should return false if externalRoomId was provided in the wrong format FederationJoinExternalPublicRoomProps', () => {
assert.isFalse(
isFederationJoinExternalPublicRoomProps({
externalRoomId: 'wrongFormatId',
}),
);
});
it('should accept a externalRoomId with nothing else', () => {
assert.isTrue(
isFederationJoinExternalPublicRoomProps({
externalRoomId: '!externalRoomId:server.com',
}),
);
});
it('should return false when extra parameters are provided to FederationJoinExternalPublicRoomProps', () => {
assert.isFalse(
isFederationJoinExternalPublicRoomProps({
externalRoomId: '!externalRoomId:server.com',
extra: 'extra',
}),
);
});
});
});

View File

@@ -0,0 +1,37 @@
import { isFederationSearchPublicRoomsProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('FederationPublicRoomProps (definition/rest/v1)', () => {
describe('isFederationSearchPublicRoomsProps', () => {
it('should be a function', () => {
assert.isFunction(isFederationSearchPublicRoomsProps);
});
it('should return false when provided anything that is not an FederationPublicRoomProps', () => {
assert.isFalse(isFederationSearchPublicRoomsProps(''));
assert.isFalse(isFederationSearchPublicRoomsProps(123));
});
it('should accept a externalRoomId with nothing else', () => {
assert.isTrue(
isFederationSearchPublicRoomsProps({
serverName: 'server',
roomName: 'roomName',
count: '1',
pageToken: 'token',
}),
);
});
it('should return false when extra parameters are provided to FederationPublicRoomProps', () => {
assert.isFalse(
isFederationSearchPublicRoomsProps({
serverName: 'server',
roomName: 'roomName',
count: '1',
pageToken: 'token',
extra: 'extra',
}),
);
});
});
});

View File

@@ -0,0 +1,41 @@
import { isFederationRemoveServerProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('FederationRemoveServerProps (definition/rest/v1)', () => {
describe('isFederationRemoveServerProps', () => {
it('should be a function', () => {
assert.isFunction(isFederationRemoveServerProps);
});
it('should return false when provided anything that is not an FederationRemoveServerProps', () => {
assert.isFalse(isFederationRemoveServerProps(undefined));
assert.isFalse(isFederationRemoveServerProps(null));
assert.isFalse(isFederationRemoveServerProps(''));
assert.isFalse(isFederationRemoveServerProps(123));
assert.isFalse(isFederationRemoveServerProps({}));
assert.isFalse(isFederationRemoveServerProps([]));
assert.isFalse(isFederationRemoveServerProps(new Date()));
assert.isFalse(isFederationRemoveServerProps(new Error()));
});
it('should return false if serverName is not provided to FederationRemoveServerProps', () => {
assert.isFalse(isFederationRemoveServerProps({}));
});
it('should accept a serverName with nothing else', () => {
assert.isTrue(
isFederationRemoveServerProps({
serverName: 'serverName',
}),
);
});
it('should return false when extra parameters are provided to FederationRemoveServerProps', () => {
assert.isFalse(
isFederationRemoveServerProps({
serverName: 'serverName',
extra: 'extra',
}),
);
});
});
});

View File

@@ -0,0 +1,91 @@
import { isTeamsAddMembersProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('TeamsAddMemberProps (definition/rest/v1)', () => {
describe('isTeamsAddMembersProps', () => {
it('should be a function', () => {
assert.isFunction(isTeamsAddMembersProps);
});
it('should return false if the parameter is empty', () => {
assert.isFalse(isTeamsAddMembersProps({}));
});
it('should return false if teamId is provided but no member was provided', () => {
assert.isFalse(isTeamsAddMembersProps({ teamId: '123' }));
});
it('should return false if teamName is provided but no member was provided', () => {
assert.isFalse(isTeamsAddMembersProps({ teamName: '123' }));
});
it('should return false if members is provided but no teamId or teamName were provided', () => {
assert.isFalse(isTeamsAddMembersProps({ members: [{ userId: '123' }] }));
});
it('should return false if teamName was provided but members are empty', () => {
assert.isFalse(isTeamsAddMembersProps({ teamName: '123', members: [] }));
});
it('should return false if teamId was provided but members are empty', () => {
assert.isFalse(isTeamsAddMembersProps({ teamId: '123', members: [] }));
});
it('should return false if members with role is provided but no teamId or teamName were provided', () => {
assert.isFalse(isTeamsAddMembersProps({ members: [{ userId: '123', roles: ['123'] }] }));
});
it('should return true if members is provided and teamId is provided', () => {
assert.isTrue(isTeamsAddMembersProps({ members: [{ userId: '123' }], teamId: '123' }));
});
it('should return true if members is provided and teamName is provided', () => {
assert.isTrue(isTeamsAddMembersProps({ members: [{ userId: '123' }], teamName: '123' }));
});
it('should return true if members with role is provided and teamId is provided', () => {
assert.isTrue(isTeamsAddMembersProps({ members: [{ userId: '123', roles: ['123'] }], teamId: '123' }));
});
it('should return true if members with role is provided and teamName is provided', () => {
assert.isTrue(isTeamsAddMembersProps({ members: [{ userId: '123', roles: ['123'] }], teamName: '123' }));
});
it('should return false if teamName was provided and members contains an invalid property', () => {
assert.isFalse(
isTeamsAddMembersProps({
teamName: '123',
members: [{ userId: '123', roles: ['123'], invalid: true }],
}),
);
});
it('should return false if teamId was provided and members contains an invalid property', () => {
assert.isFalse(
isTeamsAddMembersProps({
teamId: '123',
members: [{ userId: '123', roles: ['123'], invalid: true }],
}),
);
});
it('should return false if teamName informed but contains an invalid property', () => {
assert.isFalse(
isTeamsAddMembersProps({
member: [{ userId: '123', roles: ['123'] }],
teamName: '123',
invalid: true,
}),
);
});
it('should return false if teamId informed but contains an invalid property', () => {
assert.isFalse(
isTeamsAddMembersProps({
member: [{ userId: '123', roles: ['123'] }],
teamId: '123',
invalid: true,
}),
);
});
});
});

View File

@@ -0,0 +1,34 @@
import { isTeamsConvertToChannelProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('TeamsConvertToChannelProps (definition/rest/v1)', () => {
describe('isTeamsConvertToChannelProps', () => {
it('should be a function', () => {
assert.isFunction(isTeamsConvertToChannelProps);
});
it('should return false if neither teamName or teamId is provided', () => {
assert.isFalse(isTeamsConvertToChannelProps({}));
});
it('should return true if teamName is provided', () => {
assert.isTrue(isTeamsConvertToChannelProps({ teamName: 'teamName' }));
});
it('should return true if teamId is provided', () => {
assert.isTrue(isTeamsConvertToChannelProps({ teamId: 'teamId' }));
});
it('should return false if both teamName and teamId are provided', () => {
assert.isFalse(isTeamsConvertToChannelProps({ teamName: 'teamName', teamId: 'teamId' }));
});
it('should return false if an additionalProperties is provided', () => {
assert.isFalse(
isTeamsConvertToChannelProps({
teamName: 'teamName',
additionalProperties: 'additionalProperties',
}),
);
});
});
});

View File

@@ -0,0 +1,60 @@
import { isTeamsDeleteProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('TeamsDeleteProps (definition/rest/v1)', () => {
describe('isTeamsDeleteProps', () => {
it('should be a function', () => {
assert.isFunction(isTeamsDeleteProps);
});
it('should return false if neither teamName or teamId is provided', () => {
assert.isFalse(isTeamsDeleteProps({}));
});
it('should return true if teamId is provided', () => {
assert.isTrue(isTeamsDeleteProps({ teamId: 'teamId' }));
});
it('should return true if teamName is provided', () => {
assert.isTrue(isTeamsDeleteProps({ teamName: 'teamName' }));
});
it('should return false if teamId and roomsToRemove are provided, but roomsToRemove is empty', () => {
assert.isFalse(isTeamsDeleteProps({ teamId: 'teamId', roomsToRemove: [] }));
});
it('should return false if teamName and roomsToRemove are provided, but roomsToRemove is empty', () => {
assert.isFalse(isTeamsDeleteProps({ teamName: 'teamName', roomsToRemove: [] }));
});
it('should return true if teamId and roomsToRemove are provided', () => {
assert.isTrue(isTeamsDeleteProps({ teamId: 'teamId', roomsToRemove: ['roomId'] }));
});
it('should return true if teamName and roomsToRemove are provided', () => {
assert.isTrue(isTeamsDeleteProps({ teamName: 'teamName', roomsToRemove: ['roomId'] }));
});
it('should return false if teamId and roomsToRemove are provided, but roomsToRemove is not an array', () => {
assert.isFalse(isTeamsDeleteProps({ teamId: 'teamId', roomsToRemove: {} }));
});
it('should return false if teamName and roomsToRemove are provided, but roomsToRemove is not an array', () => {
assert.isFalse(isTeamsDeleteProps({ teamName: 'teamName', roomsToRemove: {} }));
});
it('should return false if teamName and rooms are provided but an extra property is provided', () => {
assert.isFalse(
isTeamsDeleteProps({
teamName: 'teamName',
roomsToRemove: ['roomsToRemove'],
extra: 'extra',
}),
);
});
it('should return false if teamId and rooms are provided but an extra property is provided', () => {
assert.isFalse(isTeamsDeleteProps({ teamId: 'teamId', roomsToRemove: ['roomsToRemove'], extra: 'extra' }));
});
});
});

View File

@@ -0,0 +1,62 @@
import { isTeamsLeaveProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('TeamsLeaveProps (definition/rest/v1)', () => {
describe('isTeamsLeaveProps', () => {
it('should be a function', () => {
assert.isFunction(isTeamsLeaveProps);
});
it('should return false if neither teamName or teamId is provided', () => {
assert.isFalse(isTeamsLeaveProps({}));
});
it('should return true if teamId is provided', () => {
assert.isTrue(isTeamsLeaveProps({ teamId: 'teamId' }));
});
it('should return true if teamName is provided', () => {
assert.isTrue(isTeamsLeaveProps({ teamName: 'teamName' }));
});
it('should return false if teamId and roomsToRemove are provided, but roomsToRemove is empty', () => {
assert.isFalse(isTeamsLeaveProps({ teamId: 'teamId', rooms: [] }));
});
it('should return true if teamId are provided, but rooms are undefined', () => {
assert.isTrue(isTeamsLeaveProps({ teamId: 'teamId', rooms: undefined }));
});
it('should return false if teamName and rooms are provided, but rooms is empty', () => {
assert.isFalse(isTeamsLeaveProps({ teamName: 'teamName', rooms: [] }));
});
it('should return true if teamName are provided, but rooms are undefined', () => {
assert.isTrue(isTeamsLeaveProps({ teamName: 'teamName', rooms: undefined }));
});
it('should return true if teamId and rooms are provided', () => {
assert.isTrue(isTeamsLeaveProps({ teamId: 'teamId', rooms: ['roomId'] }));
});
it('should return true if teamName and rooms are provided', () => {
assert.isTrue(isTeamsLeaveProps({ teamName: 'teamName', rooms: ['roomId'] }));
});
it('should return false if teamId and rooms are provided, but rooms is not an array', () => {
assert.isFalse(isTeamsLeaveProps({ teamId: 'teamId', rooms: {} }));
});
it('should return false if teamName and rooms are provided, but rooms is not an array', () => {
assert.isFalse(isTeamsLeaveProps({ teamName: 'teamName', rooms: {} }));
});
it('should return false if teamName and rooms are provided but an extra property is provided', () => {
assert.isFalse(isTeamsLeaveProps({ teamName: 'teamName', rooms: ['rooms'], extra: 'extra' }));
});
it('should return false if teamId and rooms are provided but an extra property is provided', () => {
assert.isFalse(isTeamsLeaveProps({ teamId: 'teamId', rooms: ['rooms'], extra: 'extra' }));
});
});
});

View File

@@ -0,0 +1,83 @@
import { isTeamsRemoveMemberProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('Teams (definition/rest/v1)', () => {
describe('isTeamsRemoveMemberProps', () => {
it('should be a function', () => {
assert.isFunction(isTeamsRemoveMemberProps);
});
it('should return false if parameter is empty', () => {
assert.isFalse(isTeamsRemoveMemberProps({}));
});
it('should return false if teamId is is informed but missing userId', () => {
assert.isFalse(isTeamsRemoveMemberProps({ teamId: 'teamId' }));
});
it('should return false if teamName is is informed but missing userId', () => {
assert.isFalse(isTeamsRemoveMemberProps({ teamName: 'teamName' }));
});
it('should return true if teamId and userId are informed', () => {
assert.isTrue(isTeamsRemoveMemberProps({ teamId: 'teamId', userId: 'userId' }));
});
it('should return true if teamName and userId are informed', () => {
assert.isTrue(isTeamsRemoveMemberProps({ teamName: 'teamName', userId: 'userId' }));
});
it('should return false if teamName and userId are informed but rooms are empty', () => {
assert.isFalse(isTeamsRemoveMemberProps({ teamName: 'teamName', userId: 'userId', rooms: [] }));
});
it('should return true if teamName and userId are informed but rooms are undefined', () => {
assert.isTrue(isTeamsRemoveMemberProps({ teamName: 'teamName', userId: 'userId', rooms: undefined }));
});
it('should return false if teamId and userId are informed and rooms are empty', () => {
assert.isFalse(isTeamsRemoveMemberProps({ teamId: 'teamId', userId: 'userId', rooms: [] }));
});
it('should return true if teamId and userId are informed but rooms are undefined', () => {
assert.isTrue(isTeamsRemoveMemberProps({ teamId: 'teamId', userId: 'userId', rooms: undefined }));
});
it('should return false if teamId and userId are informed but rooms are empty', () => {
assert.isFalse(isTeamsRemoveMemberProps({ teamId: 'teamId', userId: 'userId', rooms: [] }));
});
it('should return true if teamId and userId are informed and rooms are informed', () => {
assert.isTrue(isTeamsRemoveMemberProps({ teamId: 'teamId', userId: 'userId', rooms: ['room'] }));
});
it('should return false if teamName and userId are informed and rooms are informed but there is an extra property', () => {
assert.isFalse(
isTeamsRemoveMemberProps({
teamName: 'teamName',
userId: 'userId',
rooms: ['room'],
extra: 'extra',
}),
);
});
it('should return false if teamId and userId are informed and rooms are informed but there is an extra property', () => {
assert.isFalse(
isTeamsRemoveMemberProps({
teamId: 'teamId',
userId: 'userId',
rooms: ['room'],
extra: 'extra',
}),
);
});
it('should return false if teamName and userId are informed and rooms are informed but there is an extra property', () => {
assert.isFalse(
isTeamsRemoveMemberProps({
teamName: 'teamName',
userId: 'userId',
rooms: ['room'],
extra: 'extra',
}),
);
});
});
});

View File

@@ -0,0 +1,25 @@
import { isTeamsRemoveRoomProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('TeamsRemoveRoomProps (definition/rest/v1)', () => {
describe('isTeamsRemoveRoomProps', () => {
it('should be a function', () => {
assert.isFunction(isTeamsRemoveRoomProps);
});
it('should return false if roomId is not provided', () => {
assert.isFalse(isTeamsRemoveRoomProps({}));
});
it('should return false if roomId is provided but no teamId or teamName were provided', () => {
assert.isFalse(isTeamsRemoveRoomProps({ roomId: 'roomId' }));
});
it('should return false if roomId is provided and teamId is provided', () => {
assert.isTrue(isTeamsRemoveRoomProps({ roomId: 'roomId', teamId: 'teamId' }));
});
it('should return true if roomId is provided and teamName is provided', () => {
assert.isTrue(isTeamsRemoveRoomProps({ roomId: 'roomId', teamName: 'teamName' }));
});
it('should return false if roomId and teamName are provided but an additional property is provided', () => {
assert.isFalse(isTeamsRemoveRoomProps({ roomId: 'roomId', teamName: 'teamName', foo: 'bar' }));
});
});
});

View File

@@ -0,0 +1,63 @@
import { isTeamsUpdateMemberProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('TeamsUpdateMemberProps (definition/rest/v1)', () => {
describe('isTeamsUpdateMemberProps', () => {
it('should be a function', () => {
assert.isFunction(isTeamsUpdateMemberProps);
});
it('should return false if the parameter is empty', () => {
assert.isFalse(isTeamsUpdateMemberProps({}));
});
it('should return false if teamId is provided but no member was provided', () => {
assert.isFalse(isTeamsUpdateMemberProps({ teamId: '123' }));
});
it('should return false if teamName is provided but no member was provided', () => {
assert.isFalse(isTeamsUpdateMemberProps({ teamName: '123' }));
});
it('should return false if member is provided but no teamId or teamName were provided', () => {
assert.isFalse(isTeamsUpdateMemberProps({ member: { userId: '123' } }));
});
it('should return false if member with role is provided but no teamId or teamName were provided', () => {
assert.isFalse(isTeamsUpdateMemberProps({ member: { userId: '123', roles: ['123'] } }));
});
it('should return true if member is provided and teamId is provided', () => {
assert.isTrue(isTeamsUpdateMemberProps({ member: { userId: '123' }, teamId: '123' }));
});
it('should return true if member is provided and teamName is provided', () => {
assert.isTrue(isTeamsUpdateMemberProps({ member: { userId: '123' }, teamName: '123' }));
});
it('should return true if member with role is provided and teamId is provided', () => {
assert.isTrue(isTeamsUpdateMemberProps({ member: { userId: '123', roles: ['123'] }, teamId: '123' }));
});
it('should return true if member with role is provided and teamName is provided', () => {
assert.isTrue(isTeamsUpdateMemberProps({ member: { userId: '123', roles: ['123'] }, teamName: '123' }));
});
it('should return false if teamName was provided and member contains an invalid property', () => {
assert.isFalse(isTeamsUpdateMemberProps({ member: { userId: '123', invalid: '123' }, teamName: '123' }));
});
it('should return false if teamId was provided and member contains an invalid property', () => {
assert.isFalse(isTeamsUpdateMemberProps({ member: { userId: '123', invalid: '123' }, teamId: '123' }));
});
it('should return false if contains an invalid property', () => {
assert.isFalse(
isTeamsUpdateMemberProps({
member: { userId: '123', roles: ['123'] },
teamName: '123',
invalid: true,
}),
);
});
});
});

View File

@@ -0,0 +1,191 @@
import { isTeamsUpdateProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('TeamsUpdateMemberProps (definition/rest/v1)', () => {
describe('isTeamsUpdateProps', () => {
it('should be a function', () => {
assert.isFunction(isTeamsUpdateProps);
});
it('should return false when provided anything that is not an TeamsUpdateProps', () => {
assert.isFalse(isTeamsUpdateProps(undefined));
assert.isFalse(isTeamsUpdateProps(null));
assert.isFalse(isTeamsUpdateProps(''));
assert.isFalse(isTeamsUpdateProps(123));
assert.isFalse(isTeamsUpdateProps({}));
assert.isFalse(isTeamsUpdateProps([]));
assert.isFalse(isTeamsUpdateProps(new Date()));
assert.isFalse(isTeamsUpdateProps(new Error()));
});
it('should return false when only teamName is provided to TeamsUpdateProps', () => {
assert.isFalse(
isTeamsUpdateProps({
teamName: 'teamName',
}),
);
});
it('should return false when only teamId is provided to TeamsUpdateProps', () => {
assert.isFalse(
isTeamsUpdateProps({
teamId: 'teamId',
}),
);
});
it('should return false when teamName and data are provided to TeamsUpdateProps but data is an empty object', () => {
assert.isFalse(
isTeamsUpdateProps({
teamName: 'teamName',
data: {},
}),
);
});
it('should return false when teamId and data are provided to TeamsUpdateProps but data is an empty object', () => {
assert.isFalse(
isTeamsUpdateProps({
teamId: 'teamId',
data: {},
}),
);
});
it('should return false when teamName and data are provided to TeamsUpdateProps but data is not an object', () => {
assert.isFalse(
isTeamsUpdateProps({
teamName: 'teamName',
data: 'data',
}),
);
});
it('should return false when teamId and data are provided to TeamsUpdateProps but data is not an object', () => {
assert.isFalse(
isTeamsUpdateProps({
teamId: 'teamId',
data: 'data',
}),
);
});
it('should return true when teamName and data.name are provided to TeamsUpdateProps', () => {
assert.isTrue(
isTeamsUpdateProps({
teamName: 'teamName',
data: {
name: 'name',
},
}),
);
});
it('should return true when teamId and data.name are provided to TeamsUpdateProps', () => {
assert.isTrue(
isTeamsUpdateProps({
teamId: 'teamId',
data: {
name: 'name',
},
}),
);
});
it('should return true when teamName and data.type are provided to TeamsUpdateProps', () => {
assert.isTrue(
isTeamsUpdateProps({
teamName: 'teamName',
data: {
type: 0,
},
}),
);
});
it('should return true when teamId and data.type are provided to TeamsUpdateProps', () => {
assert.isTrue(
isTeamsUpdateProps({
teamId: 'teamId',
data: {
type: 0,
},
}),
);
});
it('should return true when teamName and data.name and data.type are provided to TeamsUpdateProps', () => {
assert.isTrue(
isTeamsUpdateProps({
teamName: 'teamName',
data: {
name: 'name',
type: 0,
},
}),
);
});
it('should return true when teamId and data.name and data.type are provided to TeamsUpdateProps', () => {
assert.isTrue(
isTeamsUpdateProps({
teamId: 'teamId',
data: {
name: 'name',
type: 0,
},
}),
);
});
it('should return false when teamName, data.name, data.type are some more extra data are provided to TeamsUpdateProps', () => {
assert.isFalse(
isTeamsUpdateProps({
teamName: 'teamName',
data: {
name: 'name',
type: 0,
extra: 'extra',
},
}),
);
});
it('should return false when teamId, data.name, data.type are some more extra data are provided to TeamsUpdateProps', () => {
assert.isFalse(
isTeamsUpdateProps({
teamId: 'teamId',
data: {
name: 'name',
type: 0,
extra: 'extra',
},
}),
);
});
it('should return false when teamName, data.name, data.type are some more extra parameter are provided to TeamsUpdateProps', () => {
assert.isFalse(
isTeamsUpdateProps({
teamName: 'teamName',
extra: 'extra',
data: {
name: 'name',
type: 0,
},
}),
);
});
it('should return false when teamId, data.name, data.type are some more extra parameter are provided to TeamsUpdateProps', () => {
assert.isFalse(
isTeamsUpdateProps({
teamId: 'teamId',
extra: 'extra',
data: {
name: 'name',
type: 0,
},
}),
);
});
});
});

View File

@@ -0,0 +1,40 @@
import { isVideoConfCancelProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('VideoConfCancelProps (definition/rest/v1)', () => {
describe('isVideoConfCancelProps', () => {
it('should be a function', () => {
assert.isFunction(isVideoConfCancelProps);
});
it('should return false when provided anything that is not an VideoConfCancelProps', () => {
assert.isFalse(isVideoConfCancelProps(undefined));
assert.isFalse(isVideoConfCancelProps(null));
assert.isFalse(isVideoConfCancelProps(''));
assert.isFalse(isVideoConfCancelProps(123));
assert.isFalse(isVideoConfCancelProps({}));
assert.isFalse(isVideoConfCancelProps([]));
assert.isFalse(isVideoConfCancelProps(new Date()));
assert.isFalse(isVideoConfCancelProps(new Error()));
});
it('should return false if callId is not provided to VideoConfCancelProps', () => {
assert.isFalse(isVideoConfCancelProps({}));
});
it('should accept a callId with nothing else', () => {
assert.isTrue(
isVideoConfCancelProps({
callId: 'callId',
}),
);
});
it('should return false when extra parameters are provided to VideoConfCancelProps', () => {
assert.isFalse(
isVideoConfCancelProps({
callId: 'callId',
extra: 'extra',
}),
);
});
});
});

View File

@@ -0,0 +1,40 @@
import { isVideoConfInfoProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('VideoConfInfoProps (definition/rest/v1)', () => {
describe('isVideoConfInfoProps', () => {
it('should be a function', () => {
assert.isFunction(isVideoConfInfoProps);
});
it('should return false when provided anything that is not an VideoConfInfoProps', () => {
assert.isFalse(isVideoConfInfoProps(undefined));
assert.isFalse(isVideoConfInfoProps(null));
assert.isFalse(isVideoConfInfoProps(''));
assert.isFalse(isVideoConfInfoProps(123));
assert.isFalse(isVideoConfInfoProps({}));
assert.isFalse(isVideoConfInfoProps([]));
assert.isFalse(isVideoConfInfoProps(new Date()));
assert.isFalse(isVideoConfInfoProps(new Error()));
});
it('should return false if callId is not provided to VideoConfInfoProps', () => {
assert.isFalse(isVideoConfInfoProps({}));
});
it('should accept a callId with nothing else', () => {
assert.isTrue(
isVideoConfInfoProps({
callId: 'callId',
}),
);
});
it('should return false when extra parameters are provided to VideoConfInfoProps', () => {
assert.isFalse(
isVideoConfInfoProps({
callId: 'callId',
extra: 'extra',
}),
);
});
});
});

View File

@@ -0,0 +1,45 @@
import { isVideoConfJoinProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('VideoConfJoinProps (definition/rest/v1)', () => {
describe('isVideoConfJoinProps', () => {
it('should be a function', () => {
assert.isFunction(isVideoConfJoinProps);
});
it('should return false when provided anything that is not an VideoConfJoinProps', () => {
assert.isFalse(isVideoConfJoinProps(undefined));
assert.isFalse(isVideoConfJoinProps(null));
assert.isFalse(isVideoConfJoinProps(''));
assert.isFalse(isVideoConfJoinProps(123));
assert.isFalse(isVideoConfJoinProps({}));
assert.isFalse(isVideoConfJoinProps([]));
assert.isFalse(isVideoConfJoinProps(new Date()));
assert.isFalse(isVideoConfJoinProps(new Error()));
});
it('should return false if callId is not provided to VideoConfJoinProps', () => {
assert.isFalse(isVideoConfJoinProps({}));
});
it('should accept a callId with nothing else', () => {
assert.isTrue(
isVideoConfJoinProps({
callId: 'callId',
}),
);
});
it('should return false when provided with an invalid state', () => {
assert.isFalse(isVideoConfJoinProps({ callId: 'callId', state: 123 }));
assert.isFalse(isVideoConfJoinProps({ callId: 'callId', state: [] }));
});
it('should return false when extra parameters are provided to VideoConfJoinProps', () => {
assert.isFalse(
isVideoConfJoinProps({
callId: 'callId',
extra: 'extra',
}),
);
});
});
});

View File

@@ -0,0 +1,50 @@
import { isVideoConfListProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('VideoConfListProps (definition/rest/v1)', () => {
describe('isVideoConfListProps', () => {
it('should be a function', () => {
assert.isFunction(isVideoConfListProps);
});
it('should return false when provided anything that is not an VideoConfListProps', () => {
assert.isFalse(isVideoConfListProps(undefined));
assert.isFalse(isVideoConfListProps(null));
assert.isFalse(isVideoConfListProps(''));
assert.isFalse(isVideoConfListProps(123));
assert.isFalse(isVideoConfListProps({}));
assert.isFalse(isVideoConfListProps([]));
assert.isFalse(isVideoConfListProps(new Date()));
assert.isFalse(isVideoConfListProps(new Error()));
});
it('should return false if roomId is not provided to VideoConfListProps', () => {
assert.isFalse(isVideoConfListProps({}));
});
it('should accept a roomId with nothing else', () => {
assert.isTrue(
isVideoConfListProps({
roomId: 'roomId',
}),
);
});
it('should accept the count and offset parameters', () => {
assert.isTrue(
isVideoConfListProps({
roomId: 'roomId',
offset: 50,
count: 25,
}),
);
});
it('should return false when extra parameters are provided to VideoConfListProps', () => {
assert.isFalse(
isVideoConfListProps({
roomId: 'roomId',
extra: 'extra',
}),
);
});
});
});

View File

@@ -0,0 +1,58 @@
import { isVideoConfStartProps } from '@rocket.chat/rest-typings';
import { assert } from 'chai';
describe('VideoConfStartProps (definition/rest/v1)', () => {
describe('isVideoConfStartProps', () => {
it('should be a function', () => {
assert.isFunction(isVideoConfStartProps);
});
it('should return false when provided anything that is not an VideoConfStartProps', () => {
assert.isFalse(isVideoConfStartProps(undefined));
assert.isFalse(isVideoConfStartProps(null));
assert.isFalse(isVideoConfStartProps(''));
assert.isFalse(isVideoConfStartProps(123));
assert.isFalse(isVideoConfStartProps({}));
assert.isFalse(isVideoConfStartProps([]));
assert.isFalse(isVideoConfStartProps(new Date()));
assert.isFalse(isVideoConfStartProps(new Error()));
});
it('should return false if roomId is not provided to VideoConfStartProps', () => {
assert.isFalse(isVideoConfStartProps({}));
});
it('should accept a roomId with nothing else', () => {
assert.isTrue(
isVideoConfStartProps({
roomId: 'roomId',
}),
);
});
it('should accept the allowRinging parameter', () => {
assert.isTrue(
isVideoConfStartProps({
roomId: 'roomId',
allowRinging: true,
}),
);
});
it('should accept a roomId with a title', () => {
assert.isTrue(
isVideoConfStartProps({
roomId: 'roomId',
title: 'extra',
}),
);
});
it('should return false when extra parameters are provided to VideoConfStartProps', () => {
assert.isFalse(
isVideoConfStartProps({
roomId: 'roomId',
extra: 'extra',
}),
);
});
});
});

View File

@@ -0,0 +1,39 @@
import { expect } from 'chai';
import { getMessageMaxParseLength } from '../../../lib/getMessageMaxParseLength';
describe('getMessageMaxParseLength', () => {
afterEach(() => {
delete process.env.MESSAGE_MAX_PARSE_LENGTH;
});
it('should return 0 (default) when env var is not set', () => {
delete process.env.MESSAGE_MAX_PARSE_LENGTH;
expect(getMessageMaxParseLength()).to.equal(0);
});
it('should return 0 (default) when env var is empty string', () => {
process.env.MESSAGE_MAX_PARSE_LENGTH = '';
expect(getMessageMaxParseLength()).to.equal(0);
});
it('should return 0 (default) when env var is not a number', () => {
process.env.MESSAGE_MAX_PARSE_LENGTH = 'abc';
expect(getMessageMaxParseLength()).to.equal(0);
});
it('should return the parsed number when env var is a valid integer', () => {
process.env.MESSAGE_MAX_PARSE_LENGTH = '5000';
expect(getMessageMaxParseLength()).to.equal(5000);
});
it('should return 0 when env var is "0"', () => {
process.env.MESSAGE_MAX_PARSE_LENGTH = '0';
expect(getMessageMaxParseLength()).to.equal(0);
});
it('should return 0 (default) when env var is Infinity', () => {
process.env.MESSAGE_MAX_PARSE_LENGTH = 'Infinity';
expect(getMessageMaxParseLength()).to.equal(0);
});
});

View File

@@ -0,0 +1,15 @@
import { expect } from 'chai';
import { getExtension } from '../../../app/utils/lib/mimeTypes';
describe('mimeTypes', () => {
describe('#getExtension()', () => {
it('should return an empty string if the given param is an invalid mime type', () => {
expect(getExtension('invalid-mime')).to.be.equal('');
});
it('should return the correct extension when the mime type is valid', () => {
expect(getExtension('image/png')).to.be.equal('png');
});
});
});

View File

@@ -0,0 +1,43 @@
import { expect } from 'chai';
import { getMostImportantRole } from '../../../../lib/roles/getMostImportantRole';
describe('getMostImportantRole', () => {
it('should return the same role if only one exists', () => {
expect(getMostImportantRole(['admin'])).to.be.eq('admin');
expect(getMostImportantRole(['livechat-manager'])).to.be.eq('livechat-manager');
expect(getMostImportantRole(['livechat-monitor'])).to.be.eq('livechat-monitor');
expect(getMostImportantRole(['livechat-agent'])).to.be.eq('livechat-agent');
expect(getMostImportantRole(['user'])).to.be.eq('user');
expect(getMostImportantRole(['guest'])).to.be.eq('guest');
expect(getMostImportantRole(['anonymous'])).to.be.eq('anonymous');
expect(getMostImportantRole(['app'])).to.be.eq('app');
expect(getMostImportantRole(['bot'])).to.be.eq('bot');
});
it('should return custom roles as `custom-role`', () => {
expect(getMostImportantRole(['custom1'])).to.be.eq('custom-role');
});
it('should return auditor, app and bot as `user`', () => {
expect(getMostImportantRole(['auditor'])).to.be.eq('user');
expect(getMostImportantRole(['auditor-log'])).to.be.eq('user');
});
it('should return `no-role` if no one exists', () => {
expect(getMostImportantRole([])).to.be.eq('no-role');
expect(getMostImportantRole()).to.be.eq('no-role');
});
it('should return correct role', () => {
expect(getMostImportantRole(['user', 'admin'])).to.be.eq('admin');
expect(getMostImportantRole(['user', 'anonymous'])).to.be.eq('user');
expect(getMostImportantRole(['user', 'guest'])).to.be.eq('user');
expect(getMostImportantRole(['user', 'guest', 'livechat-monitor'])).to.be.eq('livechat-monitor');
expect(getMostImportantRole(['user', 'custom1'])).to.be.eq('custom-role');
expect(getMostImportantRole(['custom2', 'user', 'custom1'])).to.be.eq('custom-role');
expect(getMostImportantRole(['custom2', 'admin', 'custom1'])).to.be.eq('admin');
expect(getMostImportantRole(['custom2', 'app'])).to.be.eq('custom-role');
expect(getMostImportantRole(['anonymous', 'app'])).to.be.eq('app');
});
});

View File

@@ -0,0 +1,19 @@
import { expect } from 'chai';
import { ensureArray } from '../../../../lib/utils/arrayUtils';
describe('Array utils', () => {
it('should return an array with one item', () => {
const value = 'value';
const result = ensureArray(value);
expect(result).to.be.an('Array').with.lengthOf(1).and.eql([value]);
});
it('should return a new array with the same items', () => {
const value = ['value1', 'value2'];
const result = ensureArray(value);
expect(result).to.be.an('Array').with.lengthOf(2).and.eql(value).and.not.equal(value);
});
});

View File

@@ -0,0 +1,22 @@
import { expect } from 'chai';
import { isRelativeURL } from '../../../../lib/utils/isRelativeURL';
describe('isRelativeURL', () => {
const testCases = [
['/', false],
['test', false], // TODO: should be true?
['test/test', true],
['.', false], // TODO: should be true?
['./test', true],
['https://rocket.chat', false],
['data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', true], // TODO: should be false?
] as const;
testCases.forEach(([parameter, expectedResult]) => {
it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(parameter)}`, () => {
const result = isRelativeURL(parameter);
expect(result).to.be.equal(expectedResult);
});
});
});

View File

@@ -0,0 +1,21 @@
import { expect } from 'chai';
import { normalizeUsername } from '../../../../lib/utils/normalizeUsername';
describe('normalizeUsername', () => {
const testCases = [
['john.doe', 'john.doe'],
['@john.doe:matrix.org', 'john.doe:matrix.org'],
['@john', 'john'],
['@@john', '@john'],
['john@doe', 'john@doe'],
['', ''],
] as const;
testCases.forEach(([parameter, expectedResult]) => {
it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(parameter)}`, () => {
const result = normalizeUsername(parameter);
expect(result).to.be.equal(expectedResult);
});
});
});

View File

@@ -0,0 +1,48 @@
import { expect } from 'chai';
import { parseCSV } from '../../../../lib/utils/parseCSV';
describe('Parse CSV', () => {
it('should return an empty array for an empty string', () => {
const result = parseCSV('');
expect(result).to.be.an('Array').with.lengthOf(0);
});
it('should return an array with one item', () => {
const value = 'value';
const result = parseCSV(value);
expect(result).to.be.an('Array').with.lengthOf(1).and.eql([value]);
});
it('should trim surrounding spaces', () => {
const value1 = 'value1';
const value2 = 'value2';
const csv = `${value1}, ${value2}`;
const result = parseCSV(csv);
expect(result).to.be.an('Array').with.lengthOf(2).and.eql([value1, value2]);
});
it('should ignore empty items', () => {
const value1 = 'value1';
const value2 = 'value2';
const csv = `${value1}, , , , ${value2}`;
const result = parseCSV(csv);
expect(result).to.be.an('Array').with.lengthOf(2).and.eql([value1, value2]);
});
it('should not ignore empty items when requested', () => {
const value1 = 'value1';
const value2 = 'value2';
const csv = `${value1}, , ${value2}`;
const result = parseCSV(csv, false);
expect(result).to.be.an('Array').with.lengthOf(3).and.eql([value1, '', value2]);
});
});

View File

@@ -0,0 +1,185 @@
import { expect } from 'chai';
import { parseParameters } from '../../../../lib/utils/parseParameters';
describe('Parse Parameters', () => {
it('should return an empty array for an empty string', () => {
const result = parseParameters('');
expect(result).to.be.an('Array').with.lengthOf(0);
});
it('should return an array with one item', () => {
const value = 'value';
const result = parseParameters(value);
expect(result).to.be.an('Array').with.lengthOf(1).and.eql([value]);
});
it('should return an array with three items', () => {
const value1 = 'value1';
const value2 = 'value2';
const value3 = 'value3';
const parameters = `${value1} ${value2} ${value3}`;
const result = parseParameters(parameters);
expect(result).to.be.an('Array').with.lengthOf(3).and.eql([value1, value2, value3]);
});
it('should ignore extra spaces by default', () => {
const value1 = 'value1';
const value2 = 'value2';
const parameters = ` ${value1} ${value2} `;
const result = parseParameters(parameters);
expect(result).to.be.an('Array').with.lengthOf(2).and.eql([value1, value2]);
});
it('should NOT ignore extra spaces when requested', () => {
const value1 = '';
const value2 = '';
const value3 = 'value3';
const value4 = '';
const value5 = '';
const value6 = 'value6';
const value7 = '';
const value8 = '';
const parameters = `${value1} ${value2} ${value3} ${value4} ${value5} ${value6} ${value7} ${value8}`;
const result = parseParameters(parameters, false);
expect(result).to.be.an('Array').with.lengthOf(8).and.eql([value1, value2, value3, value4, value5, value6, value7, value8]);
});
it('should return an array with one item without quotes', () => {
const value = 'value';
const result = parseParameters(`"${value}"`);
expect(result).to.be.an('Array').with.lengthOf(1).and.eql([value]);
});
it('should return an array with three items without quotes', () => {
const value1 = 'value1';
const value2 = 'value2';
const value3 = 'value3';
const result = parseParameters(`"${value1}" "${value2}" "${value3}"`);
expect(result).to.be.an('Array').with.lengthOf(3).and.eql([value1, value2, value3]);
});
it('should not trim spaces inside quotes', () => {
const value1 = 'value1 ';
const value2 = ' value2';
const value3 = ' value3 ';
const parameters = `"${value1}" "${value2}" "${value3}"`;
const result = parseParameters(parameters);
expect(result).to.be.an('Array').with.lengthOf(3).and.eql([value1, value2, value3]);
});
it('should split parameters even without spaces', () => {
const value1 = 'value1';
const value2 = 'value2';
const value3 = 'value3';
const value4 = 'value4';
const parameters = `"${value1}""${value2}""${value3}" "${value4}"`;
const result = parseParameters(parameters);
expect(result).to.be.an('Array').with.lengthOf(4).and.eql([value1, value2, value3, value4]);
});
it('should split parameters by both spaces and quotes in the same line', () => {
const value1 = 'value1';
const value2 = 'value2';
const parameters = `"${value1}" ${value2}`;
const result = parseParameters(parameters);
expect(result).to.be.an('Array').with.lengthOf(2).and.eql([value1, value2]);
});
it('should unescape quotes inside values', () => {
const value1 = `this is \\"value1\\" here`;
const value2 = `\\"value2\\"`;
const parameters = `"${value1}" "${value2}"`;
const result = parseParameters(parameters);
expect(result).to.be.an('Array').with.lengthOf(2);
expect(result[0]).to.be.equal(value1.replace(/\\\"/g, '"'));
expect(result[1]).to.be.equal(value2.replace(/\\\"/g, '"'));
});
it('should not ignore empty quoted parameters', () => {
const value1 = 'value1';
const value2 = '';
const value3 = 'value3';
const parameters = `"${value1}" "${value2}" "${value3}"`;
const result = parseParameters(parameters);
expect(result).to.be.an('Array').with.lengthOf(3).and.eql([value1, value2, value3]);
});
it('should accept line breaks inside quotes', () => {
const value1 = `value1
is multiline`;
const value2 = 'value2\nhas a multine too';
const value3 = 'value3\rhas a carriage return';
const parameters = `"${value1}" "${value2}" "${value3}"`;
const result = parseParameters(parameters);
expect(result).to.be.an('Array').with.lengthOf(3);
expect(result[0]).to.be.equal(value1);
expect(result[1]).to.be.equal(value2);
expect(result[2]).to.be.equal(value3);
});
it('should split on line breaks without quotes', () => {
const value1 = `value1`;
const value2 = 'value2';
const value3 = 'value3';
const parameters = `${value1}
${value2}
${value3}`;
const result = parseParameters(parameters);
expect(result).to.be.an('Array').with.lengthOf(3);
expect(result[0]).to.be.equal(value1);
expect(result[1]).to.be.equal(value2);
expect(result[2]).to.be.equal(value3);
});
it('should accept tabs inside quotes', () => {
const value1 = `value1 is tabbed`;
const value2 = 'value2';
const parameters = `"${value1}" "${value2}"`;
const result = parseParameters(parameters);
expect(result).to.be.an('Array').with.lengthOf(2);
expect(result[0]).to.be.equal(value1);
expect(result[1]).to.be.equal(value2);
});
it('should split on tabs without quotes', () => {
const value1 = `value1`;
const value2 = 'value2';
const value3 = 'value3';
const parameters = `${value1} ${value2} ${value3}`;
const result = parseParameters(parameters);
expect(result).to.be.an('Array').with.lengthOf(3);
expect(result[0]).to.be.equal(value1);
expect(result[1]).to.be.equal(value2);
expect(result[2]).to.be.equal(value3);
});
});

View File

@@ -0,0 +1,28 @@
import { expect } from 'chai';
import { secondsToHHMMSS } from '../../../../lib/utils/secondsToHHMMSS';
describe('secondsToHHMMSS', () => {
const testCases = [
[0, '00:00:00'],
[1, '00:00:01'],
[60, '00:01:00'],
[61, '00:01:01'],
[3600, '01:00:00'],
[3601, '01:00:01'],
[3661, '01:01:01'],
[3661.1, '01:01:01'],
[3661.9, '01:01:02'], // rounding down?
[3662, '01:01:02'],
[86400, '24:00:00'],
[172800, '48:00:00'],
[360000, '100:00:00'], // should exceed 8 characters?
] as const;
testCases.forEach(([parameter, expectedResult]) => {
it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(parameter)}`, () => {
const result = secondsToHHMMSS(parameter);
expect(result).to.be.equal(expectedResult);
});
});
});

View File

@@ -0,0 +1,21 @@
import { expect } from 'chai';
import { truncate } from '../../../../lib/utils/stringUtils';
describe('String utils', () => {
it('should return an empty string when the specified string is empty', () => {
expect(truncate('', 10)).to.be.equal('');
});
it('should truncate a larger string', () => {
expect(truncate('this text has a few words', 9)).to.be.equal('this t...');
});
it('should not truncate a smaller string', () => {
expect(truncate('this', 9)).to.be.equal('this');
});
it('should not truncate a string with the exact length', () => {
expect(truncate('this', 4)).to.be.equal('this');
});
});

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

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