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,141 @@
import type {
IOutboundEmailMessageProvider,
IOutboundPhoneMessageProvider,
} from '@rocket.chat/apps-engine/definition/outboundCommunication';
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import sinon from 'sinon';
import { OutboundMessageProvider } from '../../../../server/lib/OutboundMessageProvider';
describe('OutboundMessageProvider', () => {
let outboundMessageProvider: OutboundMessageProvider;
beforeEach(() => {
outboundMessageProvider = new OutboundMessageProvider();
});
it('should successfully register a phone provider', () => {
const phoneProvider: IOutboundPhoneMessageProvider = {
type: 'phone',
appId: '123',
name: 'Test Phone Provider',
sendOutboundMessage: sinon.stub(),
getProviderMetadata: sinon.stub(),
};
outboundMessageProvider.registerPhoneProvider(phoneProvider);
const providers = outboundMessageProvider.getOutboundMessageProviders('phone');
expect(providers).to.have.lengthOf(1);
expect(providers[0]).to.deep.equal({
providerId: '123',
providerName: 'Test Phone Provider',
providerType: 'phone',
});
});
it('should successfully register a email provider', () => {
const emailProvider: IOutboundEmailMessageProvider = {
type: 'email',
appId: '123',
name: 'Test Email Provider',
sendOutboundMessage: sinon.stub(),
};
outboundMessageProvider.registerEmailProvider(emailProvider);
const providers = outboundMessageProvider.getOutboundMessageProviders('email');
expect(providers).to.have.lengthOf(1);
expect(providers[0]).to.deep.equal({
providerId: '123',
providerName: 'Test Email Provider',
providerType: 'email',
});
});
it('should list currently registered providers [unfiltered]', () => {
const phoneProvider: IOutboundPhoneMessageProvider = {
type: 'phone',
appId: '123',
name: 'Test Phone Provider',
sendOutboundMessage: sinon.stub(),
getProviderMetadata: sinon.stub(),
};
const emailProvider: IOutboundEmailMessageProvider = {
type: 'email',
appId: '123',
name: 'Test Email Provider',
sendOutboundMessage: sinon.stub(),
};
outboundMessageProvider.registerPhoneProvider(phoneProvider);
outboundMessageProvider.registerEmailProvider(emailProvider);
const providers = outboundMessageProvider.getOutboundMessageProviders();
expect(providers).to.have.lengthOf(2);
expect(providers.some((provider) => provider.providerType === 'phone')).to.be.true;
expect(providers.some((provider) => provider.providerType === 'email')).to.be.true;
});
it('should list currently registered providers [filtered by type]', () => {
const phoneProvider: IOutboundPhoneMessageProvider = {
type: 'phone',
appId: '123',
name: 'Test Phone Provider',
sendOutboundMessage: sinon.stub(),
getProviderMetadata: sinon.stub(),
};
const emailProvider: IOutboundEmailMessageProvider = {
type: 'email',
appId: '123',
name: 'Test Email Provider',
sendOutboundMessage: sinon.stub(),
};
outboundMessageProvider.registerPhoneProvider(phoneProvider);
outboundMessageProvider.registerEmailProvider(emailProvider);
const providers = outboundMessageProvider.getOutboundMessageProviders('phone');
expect(providers).to.have.lengthOf(1);
expect(providers[0].providerType).to.equal('phone');
});
it('should unregister a provider', () => {
const phoneProvider: IOutboundPhoneMessageProvider = {
type: 'phone',
appId: '123',
name: 'Test Phone Provider',
sendOutboundMessage: sinon.stub(),
getProviderMetadata: sinon.stub(),
};
const phoneProvider2: IOutboundPhoneMessageProvider = {
type: 'phone',
appId: '456',
name: 'Test Phone Provider 2',
sendOutboundMessage: sinon.stub(),
getProviderMetadata: sinon.stub(),
};
outboundMessageProvider.registerPhoneProvider(phoneProvider);
outboundMessageProvider.registerPhoneProvider(phoneProvider2);
let registeredProviders = outboundMessageProvider.getOutboundMessageProviders('phone');
expect(registeredProviders).to.have.lengthOf(2);
outboundMessageProvider.unregisterProvider('123', 'phone');
registeredProviders = outboundMessageProvider.getOutboundMessageProviders('phone');
expect(registeredProviders).to.have.lengthOf(1);
expect(registeredProviders.some((provider) => provider.providerId !== '123')).to.be.true;
});
});

View File

@@ -0,0 +1,121 @@
import { expect } from 'chai';
import {
normalizers,
fromTemplate,
renameInvalidProperties,
getNestedValue,
getRegexpMatch,
} from '../../../../../../server/lib/auth-providers/custom-oauth/transform_helpers';
const data = {
'id': '123456',
'invalid.property': true,
'name': 'foo',
'email': 'foo@bar.com',
'nested': {
'value': 'baz',
'another.invalid.prop': true,
},
'list': [
{
'invalid.property': 'in-array',
},
],
};
describe('fromTemplate', () => {
const normalizedData = Object.values(normalizers).reduce((normalizedData, normalizer) => {
const result = { ...normalizedData };
normalizer({ ...result });
return result;
}, data);
it('returns match from regexp on top-level properties', () => {
const template = '{{/^foo@bar.(.+)/::email}}';
const expected = 'com';
const result = fromTemplate(template, normalizedData);
expect(result).to.equal(expected);
});
it('returns match from regexp on nested properties', () => {
const template = '{{/^ba(.+)/::nested.value}}';
const expected = 'z';
const result = fromTemplate(template, normalizedData);
expect(result).to.equal(expected);
});
it('returns value from nested prop with plain syntax', () => {
const template = 'nested.value';
const expected = normalizedData.nested.value;
const result = fromTemplate(template, normalizedData);
expect(result).to.equal(expected);
});
it('returns value from nested prop with template syntax', () => {
const template = '{{nested.value}}';
const expected = normalizedData.nested.value;
const result = fromTemplate(template, normalizedData);
expect(result).to.equal(expected);
});
it('returns composed value from nested prop with template syntax', () => {
const template = '{{name}}.{{nested.value}}';
const expected = `${normalizedData.name}.${normalizedData.nested.value}`;
const result = fromTemplate(template, normalizedData);
expect(result).to.equal(expected);
});
it('returns composed string from multiple template chunks with static parts', () => {
const template = 'composed-{{name}}-at-{{nested.value}}-dot-{{/^foo@bar.(.+)/::email}}-from-template';
const expected = 'composed-foo-at-baz-dot-com-from-template';
const result = fromTemplate(template, normalizedData);
expect(result).to.equal(expected);
});
});
describe('getRegexpMatch', () => {
it('returns nested value when formula is not in the regex::field form', () => {
const formula = 'nested.value';
expect(getRegexpMatch(formula, data)).to.equal(data.nested.value);
});
it("returns undefined when regex doesn't match", () => {
const formula = '/^foo@baz(.+)/::email';
expect(getRegexpMatch(formula, data)).to.be.undefined;
});
it("throws when regex isn't valid", () => {
const formula = '/+/::email';
expect(() => getRegexpMatch(formula, data)).to.throw();
});
});
describe('renameInvalidProperties', () => {
it('replaces . chars in field names with _', () => {
const result = renameInvalidProperties(data);
expect(result['invalid.property']).to.be.undefined;
expect(result.invalid_property).to.equal(data['invalid.property']);
expect(result.nested['invalid.property']).to.be.undefined;
expect(result.nested.invalid_property).to.equal(data.nested['invalid.property']);
result.list.forEach((item, idx) => {
expect(item['invalid.property']).to.be.undefined;
expect(item.invalid_property).to.equal(data.list[idx]['invalid.property']);
});
});
});
describe('getNestedValue', () => {
it("returns undefined when nested value doesn't exist", () => {
expect(getNestedValue('nested.does.not.exist', data)).to.be.undefined;
});
it('returns nested object property', () => {
expect(getNestedValue('nested.value', data)).to.equal(data.nested.value);
});
});

View File

@@ -0,0 +1,82 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
describe('TranslationProviderRegistry', () => {
let TranslationProviderRegistry: any;
let addStub: sinon.SinonStub;
let removeStub: sinon.SinonStub;
const message = { _id: 'msg1', rid: 'room1', msg: 'hello' } as IMessage;
const room = { _id: 'room1' } as IRoom;
const makeProvider = (name: string) => ({
_getProviderMetadata: () => ({ name }),
translateMessage: sinon.stub().resolves({ ...message, translations: { [name]: 'translated' } }),
});
const getRegisteredCallback = () => addStub.firstCall.args[1];
beforeEach(() => {
addStub = sinon.stub();
removeStub = sinon.stub();
({ TranslationProviderRegistry } = proxyquire
.noCallThru()
.noPreserveCache()
.load('../../../../../server/lib/autotranslate/autotranslate', {
'meteor/meteor': { Meteor: { startup: sinon.stub() } },
'@rocket.chat/models': { Messages: {}, Subscriptions: {} },
'../../settings': { settings: { watch: sinon.stub(), get: sinon.stub() } },
'../callbacks': { callbacks: { add: addStub, remove: removeStub, priority: { MEDIUM: 2 } } },
'../messaging/markdown': { Markdown: {} },
'../notifyListener': { notifyOnMessageChange: sinon.stub() },
}));
});
it('should remove the afterSaveMessage callback and not register a new one when disabled', () => {
TranslationProviderRegistry.setEnable(false);
expect(removeStub.calledOnceWith('afterSaveMessage', 'autotranslate')).to.be.true;
expect(addStub.called).to.be.false;
});
it('should register the afterSaveMessage callback when enabled', () => {
TranslationProviderRegistry.setEnable(true);
expect(addStub.calledOnce).to.be.true;
expect(addStub.firstCall.args[0]).to.equal('afterSaveMessage');
expect(addStub.firstCall.args[3]).to.equal('autotranslate');
});
it('should translate with the newly selected provider after switching providers while enabled', async () => {
const providerA = makeProvider('a');
const providerB = makeProvider('b');
TranslationProviderRegistry.registerProvider(providerA);
TranslationProviderRegistry.registerProvider(providerB);
TranslationProviderRegistry.setCurrentProvider('a');
TranslationProviderRegistry.setEnable(true);
const callback = getRegisteredCallback();
await callback(message, { room });
expect(providerA.translateMessage.calledOnce).to.be.true;
TranslationProviderRegistry.setCurrentProvider('b');
await callback(message, { room });
expect(providerB.translateMessage.calledOnce).to.be.true;
expect(providerA.translateMessage.calledOnce).to.be.true;
});
it('should resolve null when no provider is active, letting the callback chain keep the original message', async () => {
TranslationProviderRegistry.setCurrentProvider('missing');
TranslationProviderRegistry.setEnable(true);
const result = await getRegisteredCallback()(message, { room });
expect(result).to.be.null;
});
});

View File

@@ -0,0 +1,212 @@
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import p from 'proxyquire';
import sinon from 'sinon';
const hasPermissionAsyncMock = sinon.stub();
const hasRoleAsyncMock = sinon.stub();
const banUserFromRoomMock = sinon.stub();
const canAccessRoomAsyncMock = sinon.stub();
const modelsMock = {
Rooms: {
findOneById: sinon.stub(),
},
Subscriptions: {
findOneByRoomIdAndUserId: sinon.stub(),
},
Users: {
findOneById: sinon.stub(),
findOneByUsernameIgnoringCase: sinon.stub(),
},
Roles: {
countUsersInRole: sinon.stub(),
},
};
const roomCoordinatorMock = {
getRoomDirectives: sinon.stub(),
};
const RoomMemberActions = {
BAN: 'ban',
};
const { banUserFromRoomMethod } = p.noCallThru().load('../../../../server/lib/banUserFromRoom.ts', {
'@rocket.chat/core-typings': {
isBannedSubscription: (sub: { status?: string } | null) => sub?.status === 'BANNED',
},
'@rocket.chat/models': modelsMock,
'./authorization': { canAccessRoomAsync: canAccessRoomAsyncMock },
'./authorization/hasPermission': { hasPermissionAsync: hasPermissionAsyncMock },
'./authorization/hasRole': { hasRoleAsync: hasRoleAsyncMock },
'./rooms/banUserFromRoom': { banUserFromRoom: banUserFromRoomMock },
'../../definition/IRoomTypeConfig': { RoomMemberActions },
'./rooms/roomCoordinator': { roomCoordinator: roomCoordinatorMock },
});
describe('banUserFromRoomMethod', () => {
beforeEach(() => {
hasPermissionAsyncMock.reset();
hasRoleAsyncMock.reset();
banUserFromRoomMock.reset();
canAccessRoomAsyncMock.reset();
modelsMock.Rooms.findOneById.reset();
modelsMock.Subscriptions.findOneByRoomIdAndUserId.reset();
modelsMock.Users.findOneById.reset();
modelsMock.Users.findOneByUsernameIgnoringCase.reset();
modelsMock.Roles.countUsersInRole.reset();
roomCoordinatorMock.getRoomDirectives.reset();
});
it('should throw if user does not have ban-user permission', async () => {
hasPermissionAsyncMock.resolves(false);
await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('Not allowed');
});
it('should throw if room does not exist', async () => {
hasPermissionAsyncMock.resolves(true);
modelsMock.Rooms.findOneById.resolves(null);
await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('Not allowed');
});
it('should throw if BAN action is not allowed for the room type', async () => {
hasPermissionAsyncMock.resolves(true);
modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' });
roomCoordinatorMock.getRoomDirectives.returns({
allowMemberAction: sinon.stub().resolves(false),
});
await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('Not allowed');
});
it('should throw if fromUser does not exist', async () => {
hasPermissionAsyncMock.resolves(true);
modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' });
roomCoordinatorMock.getRoomDirectives.returns({
allowMemberAction: sinon.stub().resolves(true),
});
modelsMock.Users.findOneById.resolves(null);
await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('Invalid user');
});
it('should throw if fromUser cannot access the room', async () => {
hasPermissionAsyncMock.resolves(true);
modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' });
roomCoordinatorMock.getRoomDirectives.returns({
allowMemberAction: sinon.stub().resolves(true),
});
modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' });
canAccessRoomAsyncMock.resolves(false);
await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith(
'The required "roomId" or "roomName" param provided does not match any group',
);
});
it('should throw if the user to ban does not exist', async () => {
hasPermissionAsyncMock.resolves(true);
modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' });
roomCoordinatorMock.getRoomDirectives.returns({
allowMemberAction: sinon.stub().resolves(true),
});
modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' });
canAccessRoomAsyncMock.resolves(true);
modelsMock.Users.findOneByUsernameIgnoringCase.resolves(null);
await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('User not found');
});
it('should throw if the user to ban is not in the room', async () => {
hasPermissionAsyncMock.resolves(true);
modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' });
roomCoordinatorMock.getRoomDirectives.returns({
allowMemberAction: sinon.stub().resolves(true),
});
modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' });
canAccessRoomAsyncMock.resolves(true);
modelsMock.Users.findOneByUsernameIgnoringCase.resolves({ _id: 'bannedUserId', username: 'testuser' });
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves(null);
await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith(
'User is not in this room',
);
});
it('should throw if user is already banned', async () => {
hasPermissionAsyncMock.resolves(true);
modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' });
roomCoordinatorMock.getRoomDirectives.returns({
allowMemberAction: sinon.stub().resolves(true),
});
modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' });
canAccessRoomAsyncMock.resolves(true);
modelsMock.Users.findOneByUsernameIgnoringCase.resolves({ _id: 'bannedUserId', username: 'testuser' });
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1', status: 'BANNED' });
await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith(
'User is already banned from this room',
);
});
it('should throw if user is the last owner', async () => {
hasPermissionAsyncMock.resolves(true);
modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' });
roomCoordinatorMock.getRoomDirectives.returns({
allowMemberAction: sinon.stub().resolves(true),
});
modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' });
canAccessRoomAsyncMock.resolves(true);
modelsMock.Users.findOneByUsernameIgnoringCase.resolves({ _id: 'bannedUserId', username: 'testuser' });
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
hasRoleAsyncMock.resolves(true);
modelsMock.Roles.countUsersInRole.resolves(1);
await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('You are the last owner');
});
it('should allow banning if user is owner but not the last one', async () => {
hasPermissionAsyncMock.resolves(true);
modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' });
roomCoordinatorMock.getRoomDirectives.returns({
allowMemberAction: sinon.stub().resolves(true),
});
modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' });
canAccessRoomAsyncMock.resolves(true);
modelsMock.Users.findOneByUsernameIgnoringCase.resolves({ _id: 'bannedUserId', username: 'testuser' });
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
hasRoleAsyncMock.resolves(true);
modelsMock.Roles.countUsersInRole.resolves(2);
banUserFromRoomMock.resolves();
const result = await banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' });
expect(result).to.be.true;
expect(banUserFromRoomMock.calledOnce).to.be.true;
});
it('should successfully ban a user and return true', async () => {
hasPermissionAsyncMock.resolves(true);
modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' });
roomCoordinatorMock.getRoomDirectives.returns({
allowMemberAction: sinon.stub().resolves(true),
});
modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' });
canAccessRoomAsyncMock.resolves(true);
modelsMock.Users.findOneByUsernameIgnoringCase.resolves({ _id: 'bannedUserId', username: 'testuser' });
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
hasRoleAsyncMock.resolves(false);
banUserFromRoomMock.resolves();
const result = await banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' });
expect(result).to.be.true;
expect(banUserFromRoomMock.calledOnce).to.be.true;
expect(banUserFromRoomMock.firstCall.args[0]).to.equal('room1');
expect(banUserFromRoomMock.firstCall.args[1]).to.deep.include({ _id: 'bannedUserId', username: 'testuser' });
expect(banUserFromRoomMock.firstCall.args[2]).to.deep.include({ _id: 'fromUserId', username: 'admin' });
});
});

View File

@@ -0,0 +1,64 @@
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const hasOfflineLicenseStub = sinon.stub();
const getOldestStub = sinon.stub();
const fetchStub = sinon.stub();
const buildRegistrationDataStub = sinon.stub();
const { registerPreIntentWorkspaceWizard } = proxyquire
.noCallThru()
.load('../../../../../server/lib/cloud/registerPreIntentWorkspaceWizard.ts', {
'@rocket.chat/license': { License: { hasOfflineLicense: hasOfflineLicenseStub } },
'@rocket.chat/models': { Users: { getOldest: getOldestStub } },
'@rocket.chat/server-fetch': { serverFetch: fetchStub },
'./buildRegistrationData': { buildWorkspaceRegistrationData: buildRegistrationDataStub },
'../../settings': { settings: { get: sinon.stub().returns('https://cloud.rocket.chat') } },
'../logger/system': { SystemLogger: { error: sinon.stub() } },
});
describe('registerPreIntentWorkspaceWizard', () => {
beforeEach(() => {
hasOfflineLicenseStub.reset();
getOldestStub.reset();
fetchStub.reset();
buildRegistrationDataStub.reset();
});
it('should return false without querying users or fetching when the license is offline', async () => {
hasOfflineLicenseStub.returns(true);
const result = await registerPreIntentWorkspaceWizard();
expect(result).to.be.false;
expect(getOldestStub.called).to.be.false;
expect(fetchStub.called).to.be.false;
});
it('should not fetch when an offline license is applied while registration data is being built', async () => {
// entry check passes, dispatch-time re-check catches the license change mid-flight
hasOfflineLicenseStub.onFirstCall().returns(false);
hasOfflineLicenseStub.onSecondCall().returns(true);
getOldestStub.resolves({ emails: [{ address: 'admin@example.com' }] });
buildRegistrationDataStub.resolves({});
const result = await registerPreIntentWorkspaceWizard();
expect(result).to.be.false;
expect(fetchStub.called).to.be.false;
});
it('should contact the cloud when the license is not offline', async () => {
hasOfflineLicenseStub.returns(false);
getOldestStub.resolves({ emails: [{ address: 'admin@example.com' }] });
buildRegistrationDataStub.resolves({});
fetchStub.resolves({ ok: true });
const result = await registerPreIntentWorkspaceWizard();
expect(result).to.be.true;
expect(fetchStub.calledOnce).to.be.true;
});
});

View File

@@ -0,0 +1,101 @@
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const models = {
Settings: { updateValueById: sinon.stub() },
};
const mockedFetchWorkspaceSyncPayload = sinon.stub();
const { syncCloudData } = proxyquire.noCallThru().load('../../../../../../server/lib/cloud/syncWorkspace/syncCloudData.ts', {
'@rocket.chat/license': { DuplicatedLicenseError: sinon.stub() },
'@rocket.chat/models': models,
'../../callbacks': { callbacks: { run: sinon.stub() } },
'../../../../lib/errors/CloudWorkspaceAccessError': { CloudWorkspaceAccessError: sinon.stub() },
'../../../../lib/errors/CloudWorkspaceRegistrationError': { CloudWorkspaceRegistrationError: sinon.stub() },
'../../logger/system': { SystemLogger: { info: sinon.stub(), error: sinon.stub() } },
'../buildRegistrationData': { buildWorkspaceRegistrationData: sinon.stub().resolves({}) },
'../getWorkspaceAccessToken': {
getWorkspaceAccessToken: sinon.stub().resolves('token'),
CloudWorkspaceAccessTokenEmptyError: sinon.stub(),
},
'../retrieveRegistrationStatus': { retrieveRegistrationStatus: sinon.stub().resolves({ workspaceRegistered: true }) },
'./fetchWorkspaceSyncPayload': { fetchWorkspaceSyncPayload: mockedFetchWorkspaceSyncPayload },
});
describe('SyncCloudData', () => {
beforeEach(() => {
models.Settings.updateValueById.reset();
mockedFetchWorkspaceSyncPayload.reset();
});
it('should save cloudSyncAnnouncement payload on Cloud_Sync_Announcement_Payload setting when present', async () => {
const workspaceSyncPayloadResponse = {
workspaceId: 'workspaceId',
publicKey: 'publicKey',
license: {},
removeLicense: false,
cloudSyncAnnouncement: {
viewId: 'subscription-announcement',
appId: 'cloud-announcements-core',
blocks: [
{
type: 'callout',
title: {
type: 'plain_text',
text: 'Workspace eligible for Starter Plan',
},
text: {
type: 'plain_text',
text: 'Get free access to premium capabilities for up to 50 users',
},
accessory: {
type: 'button',
text: {
type: 'plain_text',
text: 'Switch Plan',
},
actionId: 'callout-action',
appId: 'cloud-announcements-core',
blockId: 'section-button',
},
},
],
},
};
mockedFetchWorkspaceSyncPayload.resolves(workspaceSyncPayloadResponse);
await syncCloudData();
expect(mockedFetchWorkspaceSyncPayload.calledOnce).to.be.true;
expect(
models.Settings.updateValueById.calledOnceWith(
'Cloud_Sync_Announcement_Payload',
JSON.stringify(workspaceSyncPayloadResponse.cloudSyncAnnouncement),
),
).to.be.true;
});
it("Should save as 'null' the setting update if cloudSyncAnnouncement is not present", async () => {
const workspaceSyncPayloadResponse = {
workspaceId: 'workspaceId',
publicKey: 'publicKey',
license: {},
removeLicense: false,
};
mockedFetchWorkspaceSyncPayload.resolves(workspaceSyncPayloadResponse);
await syncCloudData();
expect(mockedFetchWorkspaceSyncPayload.calledOnce).to.be.true;
expect(models.Settings.updateValueById.calledOnce).to.be.true;
expect(models.Settings.updateValueById.calledWith('Cloud_Sync_Announcement_Payload', 'null')).to.be.true;
});
});

View File

@@ -0,0 +1,72 @@
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const hasOfflineLicenseStub = sinon.stub();
const retrieveRegistrationStatusStub = sinon.stub();
const userLoggedOutStub = sinon.stub();
const fetchStub = sinon.stub();
const findOneByIdStub = sinon.stub();
const settingsGetStub = sinon.stub();
const { userLogout } = proxyquire.noCallThru().load('../../../../../server/lib/cloud/userLogout.ts', {
'@rocket.chat/license': { License: { hasOfflineLicense: hasOfflineLicenseStub } },
'@rocket.chat/models': { Users: { findOneById: findOneByIdStub } },
'@rocket.chat/server-fetch': { serverFetch: fetchStub },
'./retrieveRegistrationStatus': { retrieveRegistrationStatus: retrieveRegistrationStatusStub },
'./userLoggedOut': { userLoggedOut: userLoggedOutStub },
'../../settings': { settings: { get: settingsGetStub } },
'../logger/system': { SystemLogger: { error: sinon.stub() } },
});
describe('userLogout', () => {
beforeEach(() => {
hasOfflineLicenseStub.reset();
retrieveRegistrationStatusStub.reset();
userLoggedOutStub.reset();
fetchStub.reset();
findOneByIdStub.reset();
settingsGetStub.reset();
});
it('should still destroy local cloud credentials on logout under an offline license, without calling the revocation endpoint', async () => {
retrieveRegistrationStatusStub.resolves({ workspaceRegistered: true });
hasOfflineLicenseStub.returns(true);
userLoggedOutStub.resolves(true);
const result = await userLogout('user-id');
expect(userLoggedOutStub.calledOnceWithExactly('user-id')).to.be.true;
expect(fetchStub.called).to.be.false;
expect(result).to.be.true;
});
it('should revoke the refresh token and destroy local cloud credentials when the license is not offline', async () => {
retrieveRegistrationStatusStub.resolves({ workspaceRegistered: true });
hasOfflineLicenseStub.returns(false);
findOneByIdStub.resolves({ _id: 'user-id', services: { cloud: { refreshToken: 'refresh-token' } } });
settingsGetStub.withArgs('Cloud_Workspace_Client_Id').returns('client-id');
settingsGetStub.withArgs('Cloud_Workspace_Client_Secret').returns('client-secret');
settingsGetStub.withArgs('Cloud_Url').returns('https://cloud.rocket.chat');
fetchStub.resolves({});
userLoggedOutStub.resolves(true);
const result = await userLogout('user-id');
expect(fetchStub.calledOnce).to.be.true;
expect(fetchStub.firstCall.args[0]).to.equal('https://cloud.rocket.chat/api/oauth/revoke');
expect(userLoggedOutStub.calledOnceWithExactly('user-id')).to.be.true;
expect(result).to.be.true;
});
it('should do nothing when the workspace is not registered', async () => {
retrieveRegistrationStatusStub.resolves({ workspaceRegistered: false });
const result = await userLogout('user-id');
expect(result).to.equal('');
expect(fetchStub.called).to.be.false;
expect(userLoggedOutStub.called).to.be.false;
});
});

View File

@@ -0,0 +1,279 @@
import { faker } from '@faker-js/faker';
import { expect } from 'chai';
import { before, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import type { MessageData } from '../../../../../server/lib/dataExport/exportRoomMessagesToFile';
import { exportMessagesMock } from '../../../app/apps/server/mocks/data/messages.data';
// Create stubs for dependencies
const stubs = {
findPaginatedMessages: sinon.stub(),
mkdir: sinon.stub(),
writeFile: sinon.stub(),
findPaginatedMessagesCursor: sinon.stub(),
findPaginatedMessagesTotal: sinon.stub(),
translateKey: sinon.stub(),
settings: sinon.stub(),
};
const { getMessageData, exportRoomMessages, exportMessageObject } = proxyquire
.noCallThru()
.load('../../../../../server/lib/dataExport/exportRoomMessagesToFile.ts', {
'@rocket.chat/models': {
Messages: {
findPaginated: stubs.findPaginatedMessages,
},
},
'fs/promises': {
mkdir: stubs.mkdir,
writeFile: stubs.writeFile,
},
'../i18n': {
i18n: {
t: stubs.translateKey,
},
},
'../../settings': {
settings: stubs.settings,
},
});
describe('Export - exportMessageObject', () => {
let messagesData: MessageData[];
const translationPlaceholder = 'translation-placeholder';
before(() => {
stubs.translateKey.returns(translationPlaceholder);
messagesData = exportMessagesMock.map((message) => getMessageData(message, false));
});
it('should only stringify message object when exporting message as json', async () => {
const result = await exportMessageObject('json', messagesData[3]);
expect(result).to.be.a.string;
expect(result).to.equal(JSON.stringify(messagesData[3]));
});
it('should correctly add tags when exporting plain text message object as html', async () => {
const result = await exportMessageObject('html', messagesData[3]);
expect(result).to.be.a.string;
expect(result).to.equal(
`<p><strong>${messagesData[3].username}</strong> (${new Date(messagesData[3].ts).toUTCString()}):<br/>\n${messagesData[3].msg}\n</p>`,
);
});
it('should correctly format system messages when exporting message object as html', async () => {
const result = await exportMessageObject('html', messagesData[0]);
expect(messagesData[0].msg).to.equal(translationPlaceholder);
expect(result).to.be.a.string;
expect(result).to.equal(
`<p><strong>${messagesData[0].username}</strong> (${new Date(messagesData[0].ts).toUTCString()}):<br/>\n<i>${
messagesData[0].msg
}</i>\n</p>`,
);
});
it('should correctly format non italic system messages when exporting message object as html', async () => {
const result = await exportMessageObject('html', messagesData[4]);
expect(messagesData[4].msg).to.equal(translationPlaceholder);
expect(result).to.be.a.string;
expect(result).to.equal(
`<p><strong>${messagesData[4].username}</strong> (${new Date(messagesData[4].ts).toUTCString()}):<br/>\n${messagesData[4].msg}\n</p>`,
);
});
it('should correctly reference file when exporting a message object with an attachment as html', async () => {
const result = await exportMessageObject('html', messagesData[1], [exportMessagesMock[1].file]);
expect(result).to.be.a.string;
expect(result).to.equal(
`<p><strong>${messagesData[1].username}</strong> (${new Date(messagesData[1].ts).toUTCString()}):<br/>\n${
messagesData[1].msg
}\n<br/><a href="./assets/${exportMessagesMock[1].file?._id}-${exportMessagesMock[1].file?.name}">${
messagesData[1].attachments?.[0].title
}</a>\n</p>`,
);
});
it('should use fallback attachment description when no title is provided on message object export as html', async () => {
const result = await exportMessageObject('html', messagesData[2], [exportMessagesMock[2].file]);
expect(stubs.translateKey.calledWith('Message_Attachments')).to.be.true;
expect(result).to.be.a.string;
expect(result).to.equal(
`<p><strong>${messagesData[2].username}</strong> (${new Date(messagesData[2].ts).toUTCString()}):<br/>\n${
exportMessagesMock[1].msg
}\n<br/><a href="./assets/${exportMessagesMock[2].file?._id}-${
exportMessagesMock[2].file?.name
}">${translationPlaceholder}</a>\n</p>`,
);
});
});
describe('Export - exportMessageObject HTML escaping (XSS prevention)', () => {
const ts = new Date('2020-01-01T00:00:00.000Z');
it('should escape HTML in the message body when exporting as html', async () => {
const result = await exportMessageObject('html', {
msg: '<img src=x onerror="alert(1)">',
username: 'attacker',
ts,
});
expect(result).to.contain('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
expect(result).to.not.contain('<img src=x');
});
it('should escape HTML in the username when exporting as html', async () => {
const result = await exportMessageObject('html', {
msg: 'hello',
username: '<script>alert(1)</script>',
ts,
});
expect(result).to.contain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(result).to.not.contain('<script>');
});
it('should escape HTML inside italicized (system) messages when exporting as html', async () => {
const result = await exportMessageObject('html', {
msg: '<img src=x onerror=alert(1)>',
username: 'attacker',
ts,
type: 'wm',
});
expect(result).to.contain('<i>&lt;img src=x onerror=alert(1)&gt;</i>');
expect(result).to.not.contain('<img src=x');
});
it('should escape HTML in the attachment description and prevent href attribute breakout', async () => {
const result = await exportMessageObject(
'html',
{
msg: 'file',
username: 'attacker',
ts,
attachments: [
{
type: 'file',
title: '<img src=x onerror=alert(1)>',
title_link: '/file-upload/evil-id/x',
},
] as any,
},
[{ _id: 'evil-id', name: '"><img src=x onerror=alert(1)>.png' } as any],
);
expect(result).to.contain('&lt;img src=x');
expect(result).to.not.contain('<img src=x');
// the crafted filename must not break out of the href="" attribute
expect(result).to.not.contain('"><img');
});
it('should not escape plain JSON exports (escaping is html-only)', async () => {
const messageObject = { msg: '<b>x</b>', username: 'attacker', ts };
const result = await exportMessageObject('json', messageObject);
expect(result).to.equal(JSON.stringify(messageObject));
});
});
describe('Export - exportRoomMessages', () => {
const totalMessages = 10;
const userData = {
_id: faker.database.mongodbObjectId(),
name: faker.person.fullName(),
username: faker.internet.userName(),
};
before(() => {
stubs.findPaginatedMessagesCursor.resolves(exportMessagesMock);
stubs.findPaginatedMessagesTotal.resolves(totalMessages);
stubs.findPaginatedMessages.returns({
cursor: { toArray: stubs.findPaginatedMessagesCursor },
totalCount: stubs.findPaginatedMessagesTotal(),
});
stubs.translateKey.returns('translated-placeholder-uj');
});
it('should correctly export multiple messages to result when exporting room as json', async () => {
const result = await exportRoomMessages('test-rid', 'json', 0, 100, userData);
expect(stubs.translateKey.calledWith('User_joined_the_channel')).to.be.true;
expect(result).to.be.an('object');
expect(result).to.have.property('total', totalMessages);
expect(result).to.have.property('exported', exportMessagesMock.length);
expect(result).to.have.property('messages').that.is.an('array').of.length(exportMessagesMock.length);
const messagesWithFiles = exportMessagesMock.filter((message) => message.file);
expect(result).to.have.property('uploads').that.is.an('array').of.length(messagesWithFiles.length);
});
it('should correctly export multiple messages to result when exporting room as html', async () => {
const result = await exportRoomMessages('test-rid', 'html', 0, 100, userData);
expect(stubs.translateKey.calledWith('User_joined_the_channel')).to.be.true;
expect(result).to.be.an('object');
expect(result).to.have.property('total', totalMessages);
expect(result).to.have.property('exported', exportMessagesMock.length);
expect(result).to.have.property('messages').that.is.an('array').of.length(exportMessagesMock.length);
const messagesWithFiles = exportMessagesMock.filter((message) => message.file);
expect(result).to.have.property('uploads').that.is.an('array').of.length(messagesWithFiles.length);
});
it('should export multiple files and filter out thumbnails', async () => {
const message = {
_id: faker.database.mongodbObjectId(),
rid: 'test-rid',
ts: new Date(),
u: { _id: faker.database.mongodbObjectId(), username: 'testuser' },
msg: 'Message with files',
files: [
{ _id: 'file-1', name: 'photo.jpg', type: 'image/jpeg', size: 500000 },
{ _id: 'file-2', name: 'doc.pdf', type: 'application/pdf', size: 10000 },
{ _id: 'thumb-1', name: 'photo_thumb.jpg', type: 'image/jpeg', size: 5000, typeGroup: 'thumb' },
],
attachments: [{ type: 'file', title: 'photo.jpg', title_link: '/file-upload/file-1/photo.jpg' }],
};
stubs.findPaginatedMessagesCursor.resolves([message]);
stubs.findPaginatedMessagesTotal.resolves(1);
stubs.findPaginatedMessages.returns({
cursor: { toArray: stubs.findPaginatedMessagesCursor },
totalCount: stubs.findPaginatedMessagesTotal(),
});
const result = await exportRoomMessages('test-rid', 'html', 0, 100, userData);
expect(result.uploads).to.have.length(2);
expect(result.uploads.some((f: { typeGroup?: string }) => f.typeGroup === 'thumb')).to.be.false;
});
it('should fallback to msg.file when msg.files is not available', async () => {
const message = {
_id: faker.database.mongodbObjectId(),
rid: 'test-rid',
ts: new Date(),
u: { _id: faker.database.mongodbObjectId(), username: 'testuser' },
msg: 'Old format message',
file: { _id: 'single-file', name: 'doc.pdf', type: 'application/pdf', size: 10000 },
attachments: [{ type: 'file', title: 'doc.pdf', title_link: '/file-upload/single-file/doc.pdf' }],
};
stubs.findPaginatedMessagesCursor.resolves([message]);
stubs.findPaginatedMessagesTotal.resolves(1);
stubs.findPaginatedMessages.returns({
cursor: { toArray: stubs.findPaginatedMessagesCursor },
totalCount: stubs.findPaginatedMessagesTotal(),
});
const result = await exportRoomMessages('test-rid', 'html', 0, 100, userData);
expect(result.uploads).to.have.length(1);
expect(result.uploads[0]._id).to.equal('single-file');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,203 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const formatStub = sinon.stub().returns('00:00');
const isSameStub = sinon.stub().returns(true);
const isDSTStub = sinon.stub().returns(true);
const momentStub = sinon.stub().returns({
utc: () => ({
tz: () => ({
format: formatStub,
isSame: isSameStub,
isDST: isDSTStub,
}),
}),
});
const findActiveBusinessHoursStub = sinon.stub().returns([]);
const LivechatBusinessHoursStub = {
findActiveBusinessHours: findActiveBusinessHoursStub,
};
const saveBusinessHourStub = sinon.stub();
const loggerStub = sinon.stub();
const { BusinessHourManager } = proxyquire.noCallThru().load('../../../../../../server/lib/omnichannel/business-hour/BusinessHourManager', {
'../../../settings': {},
'../../callbacks': {},
'./AbstractBusinessHour': {},
'moment-timezone': momentStub,
'@rocket.chat/models': {
LivechatBusinessHours: LivechatBusinessHoursStub,
},
'../logger': {
businessHourLogger: {
error: loggerStub,
},
},
});
const cronAddStub = sinon.stub();
const cronRemoveStub = sinon.stub();
describe('[OC] BusinessHourManager', () => {
afterEach(() => sinon.restore());
describe('hasDaylightSavingTimeChanged()', () => {
const manager = new BusinessHourManager({} as any);
it('should return false if the provided timezone is equal to the current one (No changes in the timezone)', () => {
formatStub.returns('00:00');
expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.false;
});
it('should return false if the provided timezone is different to the current one, the current time is in DST but there is no difference between times (current, stored)', () => {
isDSTStub.returns(true);
formatStub.returns('01:00');
isSameStub.returns(true);
expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.false;
});
it('should return false if the provided timezone is different to the current one, the current time is NOT in DST but there is no difference between times (current, stored)', () => {
isDSTStub.returns(false);
formatStub.returns('01:00');
isSameStub.returns(true);
expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.false;
});
it('should return true if the provided timezone is different to the current one, the current time is in DST and the current time is different than the stored one', () => {
isDSTStub.returns(true);
formatStub.returns('01:00');
isSameStub.returns(false);
expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.true;
});
it('should return true if the provided timezone is different to the current one, the current time is NOT in DST and the current time is different than the stored one', () => {
isDSTStub.returns(false);
formatStub.returns('01:00');
isSameStub.returns(false);
expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.true;
});
});
describe('startDaylightSavingTimeVerifier()', () => {
const cronStub = {
add: cronAddStub,
remove: cronRemoveStub,
};
const manager = new BusinessHourManager(cronStub);
manager.registerBusinessHourBehavior({
onStartBusinessHours: () => {},
onDisableBusinessHours: () => {},
});
afterEach(() => {
sinon.restore();
loggerStub.resetHistory();
});
it('should call the verifier when the manager starts', async () => {
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'setupCallbacks');
sinon.stub(manager, 'cleanupDisabledDepartmentReferences');
sinon.stub(manager, 'startDaylightSavingTimeVerifier');
await manager.startManager();
expect(manager.startDaylightSavingTimeVerifier.called).to.be.true;
});
it('should register the cron job for the DST verifier when the manager starts', async () => {
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'setupCallbacks');
sinon.stub(manager, 'cleanupDisabledDepartmentReferences');
sinon.stub(manager, 'startDaylightSavingTimeVerifier');
await manager.startManager();
expect(cronAddStub.called).to.be.true;
});
it('should remove the cron job for the DST verifier when the manager stops', async () => {
sinon.stub(manager, 'removeCronJobs');
sinon.stub(manager, 'clearCronJobsCache');
sinon.stub(manager, 'removeCallbacks');
sinon.stub(manager, 'startDaylightSavingTimeVerifier');
await manager.stopManager();
expect(cronRemoveStub.called).to.be.true;
});
it('should remove and add back the cron job for the DST verifier when the manager restarts', async () => {
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'setupCallbacks');
sinon.stub(manager, 'cleanupDisabledDepartmentReferences');
sinon.stub(manager, 'startDaylightSavingTimeVerifier');
sinon.stub(manager, 'removeCronJobs');
sinon.stub(manager, 'clearCronJobsCache');
sinon.stub(manager, 'removeCallbacks');
await manager.restartManager();
expect(cronRemoveStub.called).to.be.true;
expect(cronAddStub.called).to.be.true;
});
it('should NOT save business hours when there is no active business hours', async () => {
sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub });
findActiveBusinessHoursStub.resolves([]);
await manager.startDaylightSavingTimeVerifier();
expect(saveBusinessHourStub.called).to.be.false;
});
it('should NOT save business hours when there is no timezone with DST changes', async () => {
sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub });
sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(false);
findActiveBusinessHoursStub.resolves([{ timezone: 'test' }]);
await manager.startDaylightSavingTimeVerifier();
expect(saveBusinessHourStub.called).to.be.false;
});
it('should save business hours AND recreate the cron jobs for the work hours when there is a timezone with DST changes', async () => {
sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub });
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(true);
findActiveBusinessHoursStub.resolves([
{ timezone: { name: 'timezoneName' }, workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }] },
]);
await manager.startDaylightSavingTimeVerifier();
expect(
saveBusinessHourStub.calledWith({
timezone: { name: 'timezoneName' },
timezoneName: 'timezoneName',
workHours: [{ start: 'startTime', finish: 'finishTime' }],
}),
).to.be.true;
expect(manager.createCronJobsForWorkHours.called).to.be.true;
});
it('should log any error on the updating process', async () => {
sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub });
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(true);
findActiveBusinessHoursStub.resolves([
{ timezone: { name: 'timezoneName' }, workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }] },
{ timezone: { name: 'timezoneName2' }, workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }] },
]);
const error = new Error('update error');
saveBusinessHourStub.onSecondCall().rejects(error);
await manager.startDaylightSavingTimeVerifier();
expect(loggerStub.calledWith(sinon.match({ msg: 'Failed to update business hours with new timezone' }))).to.be.true;
expect(manager.createCronJobsForWorkHours.called).to.be.true;
});
it('should NOT throw any error even if the business hour has an invalid type', async () => {
sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub });
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(true);
findActiveBusinessHoursStub.resolves([
{
type: 'invalid',
timezone: { name: 'timezoneName' },
workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }],
},
]);
await expect(manager.startDaylightSavingTimeVerifier()).not.to.be.rejected;
expect(loggerStub.called).to.be.false;
});
});
});

View File

@@ -0,0 +1,123 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../mocks/data';
const subscriptionsStub = {
findOneByRoomIdAndUserId: sinon.stub(),
removeByRoomId: sinon.stub(),
countByRoomId: sinon.stub(),
};
const livechatRoomsStub = {
findOneById: sinon.stub(),
};
const livechatStub = {
closeRoom: sinon.stub(),
};
const hasPermissionStub = sinon.stub();
const { closeLivechatRoom } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/closeLivechatRoom.ts', {
'./closeRoom': livechatStub,
'../authorization/hasPermission': {
hasPermissionAsync: hasPermissionStub,
},
'@rocket.chat/models': {
Subscriptions: subscriptionsStub,
LivechatRooms: livechatRoomsStub,
},
});
describe('closeLivechatRoom', () => {
const user = createFakeUser();
const room = createFakeRoom({ t: 'l', open: true });
const subscription = createFakeSubscription({ rid: room._id, u: { username: user.username, _id: user._id } });
beforeEach(() => {
subscriptionsStub.findOneByRoomIdAndUserId.reset();
subscriptionsStub.removeByRoomId.reset();
subscriptionsStub.countByRoomId.reset();
livechatRoomsStub.findOneById.reset();
livechatStub.closeRoom.reset();
hasPermissionStub.reset();
});
it('should not perform any operation when an invalid room id is provided', async () => {
livechatRoomsStub.findOneById.resolves(null);
hasPermissionStub.resolves(true);
await expect(closeLivechatRoom(user, room._id, {})).to.be.rejectedWith('error-invalid-room');
expect(livechatStub.closeRoom.notCalled).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.be.true;
expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true;
});
it('should not perform any operation when the caller is not subscribed to an open room and does not have the permission to close others rooms', async () => {
livechatRoomsStub.findOneById.resolves(room);
subscriptionsStub.findOneByRoomIdAndUserId.resolves(null);
hasPermissionStub.resolves(false);
await expect(closeLivechatRoom(user, room._id, {})).to.be.rejectedWith('error-not-authorized');
expect(livechatStub.closeRoom.notCalled).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true;
expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true;
});
it('should close the room when the caller is not subscribed to it but has the permission to close others rooms', async () => {
livechatRoomsStub.findOneById.resolves(room);
subscriptionsStub.findOneByRoomIdAndUserId.resolves(null);
hasPermissionStub.resolves(true);
await closeLivechatRoom(user, room._id, {});
expect(livechatStub.closeRoom.calledOnceWith(sinon.match({ room, user }))).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true;
expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true;
});
it('should close the room when the caller is subscribed to it and does not have the permission to close others rooms', async () => {
livechatRoomsStub.findOneById.resolves(room);
subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription);
hasPermissionStub.resolves(false);
await closeLivechatRoom(user, room._id, {});
expect(livechatStub.closeRoom.calledOnceWith(sinon.match({ room, user }))).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true;
expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true;
});
it('should call Livechat.closeRoom directly when forceClose is true even if room is in invalid state', async () => {
// Here we're using `open: false` as a way to simulate an invalid state. This should not happen in production. A room like this is effectively a "bad egg"
livechatRoomsStub.findOneById.resolves({ ...room, open: false });
subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription);
hasPermissionStub.resolves(true);
await closeLivechatRoom(user, room._id, { forceClose: true });
expect(
livechatStub.closeRoom.calledOnceWith(
sinon.match({ room: { ...room, open: false }, user, options: sinon.match({ clientAction: true }), forceClose: true }),
),
).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true;
});
it('should throw an error if forceClose is false and room is already closed', async () => {
livechatRoomsStub.findOneById.resolves({ ...room, open: false });
subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription);
hasPermissionStub.resolves(true);
await expect(closeLivechatRoom(user, room._id, { forceClose: false })).to.be.rejectedWith('error-room-already-closed');
expect(livechatStub.closeRoom.notCalled).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true;
});
});

View File

@@ -0,0 +1,100 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const mockUsers = {
acquireAgentLock: sinon.stub(),
releaseAgentLock: sinon.stub(),
};
const mockSettings = {
get: sinon.stub(),
};
const mockHasRoleAsync = sinon.stub();
const { conditionalLockAgent } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/conditionalLockAgent', {
'@rocket.chat/models': { Users: mockUsers },
'../authorization/hasRole': { hasRoleAsync: mockHasRoleAsync },
'../../settings': { settings: mockSettings },
});
describe('conditionalLockAgent', () => {
beforeEach(() => {
mockUsers.acquireAgentLock.reset();
mockUsers.releaseAgentLock.reset();
mockSettings.get.reset();
mockHasRoleAsync.reset();
});
describe('when waiting_queue is enabled', () => {
beforeEach(() => {
mockSettings.get.withArgs('Livechat_waiting_queue').returns(true);
mockHasRoleAsync.resolves(false);
});
it('should return acquired: true when lock is successfully acquired', async () => {
mockUsers.acquireAgentLock.resolves(true);
const result = await conditionalLockAgent('agent1');
expect(result.acquired).to.equal(true);
expect(result.required).to.equal(true);
expect(mockUsers.acquireAgentLock.calledOnce).to.equal(true);
});
it('should return acquired: false when lock is already held by another process', async () => {
mockUsers.acquireAgentLock.resolves(false);
const result = await conditionalLockAgent('agent1');
expect(result.acquired).to.equal(false);
expect(result.required).to.equal(true);
expect(mockUsers.acquireAgentLock.calledOnce).to.equal(true);
});
it('should call releaseAgentLock when unlock is called', async () => {
mockUsers.acquireAgentLock.resolves(true);
mockUsers.releaseAgentLock.resolves(true);
const result = await conditionalLockAgent('agent1');
await result.unlock();
expect(mockUsers.releaseAgentLock.calledOnce).to.equal(true);
expect(mockUsers.releaseAgentLock.firstCall.args[0]).to.equal('agent1');
expect(mockUsers.releaseAgentLock.firstCall.args[1]).to.be.instanceOf(Date);
});
it('should skip lock acquisition for bot agents', async () => {
mockHasRoleAsync.resolves(true);
const result = await conditionalLockAgent('agent1');
expect(result.acquired).to.equal(false);
expect(result.required).to.equal(false);
expect(mockUsers.acquireAgentLock.called).to.equal(false);
});
});
describe('when waiting_queue is disabled', () => {
beforeEach(() => {
mockSettings.get.withArgs('Livechat_waiting_queue').returns(false);
});
it('should return acquired: false and required: false without calling acquireAgentLock', async () => {
const result = await conditionalLockAgent('agent1');
expect(result.acquired).to.equal(false);
expect(result.required).to.equal(false);
expect(mockUsers.acquireAgentLock.called).to.equal(false);
expect(mockHasRoleAsync.called).to.equal(false);
});
it('should have a no-op unlock function', async () => {
const result = await conditionalLockAgent('agent1');
await result.unlock(); // should not throw an error
expect(mockUsers.releaseAgentLock.called).to.equal(false);
});
});
});

View File

@@ -0,0 +1,106 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatContacts: {
updateById: sinon.stub(),
},
};
const { updateContactsCustomFields } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/custom-fields.ts', {
'@rocket.chat/models': modelsMock,
});
describe('[Custom Fields] updateContactsCustomFields', () => {
beforeEach(() => {
modelsMock.LivechatContacts.updateById.reset();
});
it('should do nothing if validCustomFields param is empty', async () => {
const contact = { _id: 'contactId', customFields: {} } as any;
await updateContactsCustomFields(contact, []);
expect(modelsMock.LivechatContacts.updateById.called).to.be.false;
});
it('should add a custom field from the validCustomFields param', async () => {
const contact = { _id: 'contactId', customFields: {} } as any;
const validCustomFields = [{ key: 'field1', value: 'value1', overwrite: true }];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: { 'customFields.field1': 'value1' },
});
});
it('should add multiple custom fields from the validCustomFields param', async () => {
const contact = { _id: 'contactId', customFields: {} } as any;
const validCustomFields = [
{ key: 'field1', value: 'value1', overwrite: true },
{ key: 'field2', value: 'value2', overwrite: true },
];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: { 'customFields.field1': 'value1', 'customFields.field2': 'value2' },
});
});
it('should add custom field to conflictingFields when the contact already has the field and overwrite is false', async () => {
const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any;
const validCustomFields = [{ key: 'field1', value: 'newValue', overwrite: false }];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: { conflictingFields: [{ field: 'customFields.field1', value: 'newValue' }] },
});
});
it('should correctly add custom field and conflicting field from validCustomFields array', async () => {
const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any;
const validCustomFields = [
{ key: 'field1', value: 'newValue', overwrite: false },
{ key: 'field2', value: 'value2', overwrite: true },
];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: { 'customFields.field2': 'value2', 'conflictingFields': [{ field: 'customFields.field1', value: 'newValue' }] },
});
});
it('should overwrite an existing field when field is on validCustomFields array & overwrite is true', async () => {
const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any;
const validCustomFields = [
{ key: 'field1', value: 'newValue', overwrite: true },
{ key: 'field2', value: 'value2', overwrite: true },
];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: { 'customFields.field1': 'newValue', 'customFields.field2': 'value2' },
});
});
it('should update all custom fields from the validCustomFields array without issues', async () => {
const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any;
const validCustomFields = [
{ key: 'field1', value: 'newValue', overwrite: true },
{ key: 'field2', value: 'value2', overwrite: true },
{ key: 'field3', value: 'value3', overwrite: true },
{ key: 'field4', value: 'value4', overwrite: true },
{ key: 'field5', value: 'value5', overwrite: true },
];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: {
'customFields.field1': 'newValue',
'customFields.field2': 'value2',
'customFields.field3': 'value3',
'customFields.field4': 'value4',
'customFields.field5': 'value5',
},
});
});
});

View File

@@ -0,0 +1,105 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatContacts: {
findOneEnabledById: sinon.stub(),
disableByContactId: sinon.stub(),
},
LivechatRooms: {
checkContactOpenRooms: sinon.stub(),
},
};
const settingsMock = {
get: sinon.stub(),
};
const removeGuestMock = { removeGuest: sinon.stub() };
const { disableContactById } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/contacts/disableContact.ts', {
'@rocket.chat/models': modelsMock,
'../guests': removeGuestMock,
'../../../settings': { settings: settingsMock },
});
describe('disableContact', () => {
const contact = {
_id: 'contact-id',
channels: [
{
visitor: {
visitorId: 'visitor-id',
},
},
],
};
beforeEach(() => {
modelsMock.LivechatContacts.findOneEnabledById.reset();
modelsMock.LivechatRooms.checkContactOpenRooms.reset();
modelsMock.LivechatContacts.disableByContactId.reset();
settingsMock.get.reset();
removeGuestMock.removeGuest.reset();
});
it('should disable the contact', async () => {
settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(true);
modelsMock.LivechatContacts.findOneEnabledById.resolves(contact);
modelsMock.LivechatRooms.checkContactOpenRooms.resolves(null);
removeGuestMock.removeGuest.resolves();
modelsMock.LivechatContacts.disableByContactId.resolves();
await disableContactById(contact._id);
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true;
expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true;
expect(removeGuestMock.removeGuest.calledOnceWith({ _id: 'visitor-id' })).to.be.true;
expect(modelsMock.LivechatContacts.disableByContactId.calledOnceWith(contact._id)).to.be.true;
});
it('should call removeGuest for each channel the contact has communicated from', async () => {
contact.channels.push({ visitor: { visitorId: 'visitor-id-2' } });
settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(true);
modelsMock.LivechatContacts.findOneEnabledById.resolves(contact);
modelsMock.LivechatRooms.checkContactOpenRooms.resolves(null);
removeGuestMock.removeGuest.resolves();
modelsMock.LivechatContacts.disableByContactId.resolves();
await disableContactById(contact._id);
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true;
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true;
expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true;
expect(removeGuestMock.removeGuest.calledTwice).to.be.true;
expect(removeGuestMock.removeGuest.getCall(0).args[0]).to.deep.equal({ _id: 'visitor-id' });
expect(removeGuestMock.removeGuest.getCall(1).args[0]).to.deep.equal({ _id: 'visitor-id-2' });
expect(modelsMock.LivechatContacts.disableByContactId.calledOnceWith(contact._id)).to.be.true;
});
it('should throw error if contact is not found', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves(null);
await expect(disableContactById('nonexistent-contact-id')).to.be.rejectedWith('error-contact-not-found');
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith('nonexistent-contact-id')).to.be.true;
expect(modelsMock.LivechatRooms.checkContactOpenRooms.notCalled).to.be.true;
expect(removeGuestMock.removeGuest.notCalled).to.be.true;
expect(modelsMock.LivechatContacts.disableByContactId.notCalled).to.be.true;
});
it('should throw error if contact has open rooms and GDPR is disabled', async () => {
settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(false);
modelsMock.LivechatContacts.findOneEnabledById.resolves(contact);
modelsMock.LivechatRooms.checkContactOpenRooms.resolves({ _id: 'room-id' });
modelsMock.LivechatContacts.disableByContactId.resolves();
await expect(disableContactById(contact._id)).to.be.rejectedWith('error-contact-has-open-rooms');
expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true;
expect(removeGuestMock.removeGuest.notCalled).to.be.true;
expect(modelsMock.LivechatContacts.disableByContactId.notCalled).to.be.true;
});
});

View File

@@ -0,0 +1,35 @@
import { expect } from 'chai';
import p from 'proxyquire';
import sinon from 'sinon';
import { createFakeMessage } from '../../../../mocks/data';
const modelsMock = {
Users: {
isUserInRole: sinon.stub(),
},
};
const { isMessageFromBot } = p.noCallThru().load('../../../../../server/lib/omnichannel/isMessageFromBot', {
'@rocket.chat/models': modelsMock,
});
describe('isMessageFromBot', () => {
const mockMessage = createFakeMessage();
beforeEach(() => {
modelsMock.Users.isUserInRole.reset();
});
it('Should return true if user has bot role', async () => {
modelsMock.Users.isUserInRole.resolves(true);
const result = await isMessageFromBot(mockMessage);
expect(result).to.be.true;
});
it('Should return false if user does not have bot role', async () => {
modelsMock.Users.isUserInRole.resolves(false);
const result = await isMessageFromBot(mockMessage);
expect(result).to.be.false;
});
});

View File

@@ -0,0 +1,127 @@
import { expect } from 'chai';
import p from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
Users: {
findOneById: sinon.stub(),
},
LivechatVisitors: {
findOneById: sinon.stub(),
},
};
const settingsGetMock = {
get: sinon.stub(),
};
const { parseTranscriptRequest } = p.noCallThru().load('../../../../../server/lib/omnichannel/parseTranscriptRequest', {
'@rocket.chat/models': modelsMock,
'../../settings': { settings: settingsGetMock },
});
describe('parseTranscriptRequest', () => {
beforeEach(() => {
settingsGetMock.get.reset();
modelsMock.Users.findOneById.reset();
modelsMock.LivechatVisitors.findOneById.reset();
});
it('should return `options` param with no changes when Livechat_enable_transcript setting is true', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(true);
const options = await parseTranscriptRequest({} as any, {} as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with no changes when send always is disabled', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(false);
const options = await parseTranscriptRequest({} as any, {} as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with no changes when visitor is not provided and its not found on db', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.LivechatVisitors.findOneById.resolves(null);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with no changes when visitor is passed but no email is found', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.LivechatVisitors.findOneById.resolves({} as any);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any, { _id: '123' } as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with no changes when visitor is fetched from db, but no email is found', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.LivechatVisitors.findOneById.resolves({} as any);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with no changes when no user is passed, room is not being served and rocketcat is not present', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.Users.findOneById.resolves(null);
modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with `transcriptRequest` key attached when user is passed', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any, undefined, { _id: '123' } as any);
expect(modelsMock.LivechatVisitors.findOneById.getCall(0).firstArg).to.be.equal('123');
expect(options).to.have.property('emailTranscript').that.is.an('object');
expect(options.emailTranscript.requestData).to.have.property('email', 'abc@rocket.chat');
expect(options.emailTranscript.requestData).to.have.property('subject', '');
expect(options.emailTranscript.requestData.requestedBy).to.be.deep.equal({ _id: '123' });
});
it('should return `options` param with `transcriptRequest` key attached when no user is passed, but theres an agent serving the room', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.Users.findOneById.resolves({ _id: '123', username: 'kevsxxx', name: 'Kev' } as any);
modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any);
const options = await parseTranscriptRequest({ v: { _id: '123' }, servedBy: { _id: '123' } } as any, {} as any);
expect(modelsMock.Users.findOneById.getCall(0).firstArg).to.be.equal('123');
expect(options).to.have.property('emailTranscript').that.is.an('object');
expect(options.emailTranscript.requestData).to.have.property('email', 'abc@rocket.chat');
expect(options.emailTranscript.requestData).to.have.property('subject', '');
expect(options.emailTranscript.requestData.requestedBy).to.be.deep.equal({ _id: '123', username: 'kevsxxx', name: 'Kev' });
});
it('should return `options` param with `transcriptRequest` key attached when no user is passed, no agent is serving but rocket.cat is present', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.Users.findOneById.resolves({ _id: 'rocket.cat', username: 'rocket.cat', name: 'Rocket Cat' } as any);
modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any);
expect(modelsMock.Users.findOneById.getCall(0).firstArg).to.be.equal('rocket.cat');
expect(options).to.have.property('emailTranscript').that.is.an('object');
expect(options.emailTranscript.requestData).to.have.property('email', 'abc@rocket.chat');
expect(options.emailTranscript.requestData).to.have.property('subject', '');
expect(options.emailTranscript.requestData.requestedBy).to.be.deep.equal({
_id: 'rocket.cat',
username: 'rocket.cat',
name: 'Rocket Cat',
});
});
});

View File

@@ -0,0 +1,234 @@
import { expect } from 'chai';
import p from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatRooms: {
findOneById: sinon.stub(),
},
Messages: {
findLivechatClosingMessage: sinon.stub(),
findVisibleByRoomIdNotContainingTypesBeforeTs: sinon.stub(),
},
Users: {
findOneById: sinon.stub(),
},
};
const checkMock = sinon.stub();
const mockLogger = class {
debug() {
return null;
}
error() {
return null;
}
warn() {
return null;
}
info() {
return null;
}
};
const mockSettingValues: Record<string, any> = {
Livechat_show_agent_info: true,
Language: 'en',
From_Email: 'test@rocket.chat',
};
const settingsMock = function (key: string) {
return mockSettingValues[key] || null;
};
const getTimezoneMock = sinon.stub();
const mailerMock = sinon.stub();
const tStub = sinon.stub();
const { sendTranscript } = p.noCallThru().load('../../../../../server/lib/omnichannel/sendTranscript', {
// mocked so the spec doesn't depend on whichever broker a previously-run spec installed
'@rocket.chat/core-services': {
Message: { saveSystemMessage: sinon.stub().resolves() },
Omnichannel: { isWithinMACLimit: sinon.stub().resolves(true) },
},
'@rocket.chat/models': modelsMock,
'@rocket.chat/logger': { Logger: mockLogger },
'meteor/meteor': {
Meteor: {
Error: globalThis.Error,
},
},
'meteor/check': { check: checkMock },
'../callbacks': {
callbacks: {
run: sinon.stub(),
},
},
'../i18n': { i18n: { t: tStub } },
'../notifications/email/api': { send: mailerMock },
'../../settings': { settings: { get: settingsMock } },
'../utils/lib/getTimezone': { getTimezone: getTimezoneMock },
// TODO: add tests for file handling on transcripts
'../media/file-upload': { FileUpload: {} },
});
describe('Send transcript', () => {
beforeEach(() => {
checkMock.reset();
modelsMock.LivechatRooms.findOneById.reset();
modelsMock.Messages.findLivechatClosingMessage.reset();
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.reset();
modelsMock.Users.findOneById.reset();
mailerMock.reset();
tStub.reset();
});
it('should throw error when rid or email are invalid params', async () => {
checkMock.throws(new Error('Invalid params'));
await expect(sendTranscript({})).to.be.rejectedWith(Error);
});
it('should throw error when visitor not found', async () => {
await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error);
});
it('should attempt to send an email when params are valid using default subject', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } });
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]);
tStub.returns('Conversation Transcript');
await sendTranscript({
rid: 'rid',
token: 'token',
email: 'email',
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' },
});
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true;
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true;
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true;
expect(
mailerMock.calledWith({
to: 'email',
from: 'test@rocket.chat',
subject: 'Conversation Transcript',
replyTo: 'test@rocket.chat',
html: '<div> <hr></div>',
}),
).to.be.true;
});
it('should use provided subject', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } });
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]);
await sendTranscript({
rid: 'rid',
token: 'token',
email: 'email',
subject: 'A custom subject',
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' },
});
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true;
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true;
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true;
expect(
mailerMock.calledWith({
to: 'email',
from: 'test@rocket.chat',
subject: 'A custom subject',
replyTo: 'test@rocket.chat',
html: '<div> <hr></div>',
}),
).to.be.true;
});
it('should use subject from setting (when configured) when no subject provided', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } });
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]);
mockSettingValues.Livechat_transcript_email_subject = 'A custom subject obtained from setting.get';
await sendTranscript({
rid: 'rid',
token: 'token',
email: 'email',
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' },
});
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true;
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true;
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true;
expect(
mailerMock.calledWith({
to: 'email',
from: 'test@rocket.chat',
subject: 'A custom subject obtained from setting.get',
replyTo: 'test@rocket.chat',
html: '<div> <hr></div>',
}),
).to.be.true;
});
it('should fail if room provided is invalid', async () => {
modelsMock.LivechatRooms.findOneById.resolves(null);
await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error);
});
it('should fail if room provided is of different type', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'c' });
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error);
});
it('should fail if room is of valid type, but doesnt doesnt have `v` property', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l' });
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error);
});
it('should fail if room is of valid type, has `v` prop, but it doesnt contain `token`', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { otherProp: 'xxx' } });
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error);
});
it('should fail if room is of valid type, has `v.token`, but its different from the one on param (room from another visitor)', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'xxx' } });
await expect(sendTranscript({ rid: 'rid', email: 'email', token: 'xveasdf' })).to.be.rejectedWith(Error);
});
it('should throw an error when token is not the one on room.v', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'xxx' } });
await expect(sendTranscript({ rid: 'rid', email: 'email', token: 'xveasdf' })).to.be.rejectedWith(Error);
});
it('should work when token matches room.v', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token-123' } });
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]);
delete mockSettingValues.Livechat_transcript_email_subject;
tStub.returns('Conversation Transcript');
await sendTranscript({
rid: 'rid',
token: 'token-123',
email: 'email',
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' },
});
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true;
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true;
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true;
expect(
mailerMock.calledWith({
to: 'email',
from: 'test@rocket.chat',
subject: 'Conversation Transcript',
replyTo: 'test@rocket.chat',
html: '<div> <hr></div>',
}),
).to.be.true;
});
});

View File

@@ -0,0 +1,64 @@
import type { ILivechatCustomField } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { validateRequiredCustomFields } from '../../../../../server/lib/omnichannel/custom-fields';
describe('validateRequiredCustomFields', () => {
it('should throw an error if the required custom fields are not provided', async () => {
const customFields = ['customField1'];
const livechatCustomFields: ILivechatCustomField[] = [
{ _id: 'customField1', required: true, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
{ _id: 'customField2', required: true, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
];
try {
validateRequiredCustomFields(customFields, livechatCustomFields);
throw new Error('Expected error was not thrown');
} catch (error) {
if (error instanceof Error) {
expect(error.message).to.equal('Missing required custom fields: customField2');
} else {
throw error;
}
}
});
it('should not throw an error if all required custom fields are provided', async () => {
const customFields = ['customField1', 'customField2'];
const livechatCustomFields: ILivechatCustomField[] = [
{ _id: 'customField1', required: true, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
{ _id: 'customField2', required: true, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
];
validateRequiredCustomFields(customFields, livechatCustomFields);
});
it('should not throw an error if no custom fields are required', async () => {
const customFields = ['customField1'];
const livechatCustomFields: ILivechatCustomField[] = [
{ _id: 'customField1', required: false, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
{ _id: 'customField2', required: false, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
];
validateRequiredCustomFields(customFields, livechatCustomFields);
});
it('should throw an error if none of the required custom fields are provided', async () => {
const customFields: string[] = [];
const livechatCustomFields: ILivechatCustomField[] = [
{ _id: 'customField1', required: true, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
{ _id: 'customField2', required: true, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
];
try {
validateRequiredCustomFields(customFields, livechatCustomFields);
throw new Error('Expected error was not thrown');
} catch (error) {
if (error instanceof Error) {
expect(error.message).to.equal('Missing required custom fields: customField1, customField2');
} else {
throw error;
}
}
});
});

View File

@@ -0,0 +1,293 @@
import { expect } from 'chai';
import { describe, it, afterEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
type FetchResponse = {
status: number;
text: () => Promise<string>;
};
const mkResponse = (status: number, text = ''): FetchResponse => ({
status,
text: async () => text,
});
const MODULE_PATH = '../../../../../server/lib/omnichannel/webhooks';
function buildSubject(options?: {
fetchSequence?: Array<{ status: number; text?: string }>;
fetchOnce?: { status: number; text?: string };
settings?: Partial<Record<'Livechat_http_timeout' | 'Livechat_secret_token' | 'Livechat_webhookUrl', any>>;
}) {
const defaults = {
Livechat_http_timeout: 1000,
Livechat_secret_token: 'super-secret',
Livechat_webhookUrl: 'https://example.test/hook',
};
const settingsValues = { ...defaults, ...(options?.settings ?? {}) };
const settings = {
get: sinon.stub().callsFake((key: string) => settingsValues[key as keyof typeof settingsValues]),
};
const fetchStub = sinon.stub();
if (options?.fetchSequence && options.fetchSequence.length) {
options.fetchSequence.forEach((spec, i) => {
fetchStub.onCall(i).resolves(mkResponse(spec.status, spec.text));
});
} else if (options?.fetchOnce) {
fetchStub.resolves(mkResponse(options.fetchOnce.status, options.fetchOnce.text));
} else {
fetchStub.resolves(mkResponse(200, 'ok'));
}
const logger = {
debug: sinon.spy(),
error: sinon.spy(),
};
const metrics = {
totalLivechatWebhooksSuccess: { inc: sinon.spy() },
totalLivechatWebhooksSuccessTotal: { inc: sinon.spy() },
totalLivechatWebhooksFailures: { inc: sinon.spy() },
totalLivechatWebhooksFailuresTotal: { inc: sinon.spy() },
};
const { sendRequest } = proxyquire.noCallThru().load(MODULE_PATH, {
'@rocket.chat/server-fetch': { serverFetch: fetchStub },
'./logger': { webhooksLogger: logger },
'../metrics': { metrics },
'../../settings': { settings },
});
return {
sendRequest,
stubs: { fetchStub, logger, metrics, settings },
values: settingsValues,
};
}
describe('livechat/server/lib/webhooks sendRequest', () => {
let clock: sinon.SinonFakeTimers;
afterEach(() => {
sinon.restore();
if (clock) {
clock.restore();
}
});
it('sends a POST request with correct options and resolves on 200', async () => {
const postData = { type: 'TestEvent', a: 1 };
const secret = 'my-secret';
const timeout = 2500;
const webhookUrl = 'https://example.com/webhook';
const { sendRequest, stubs, values } = buildSubject({
fetchOnce: { status: 200, text: 'OK' },
settings: {
Livechat_secret_token: secret,
Livechat_http_timeout: timeout,
Livechat_webhookUrl: webhookUrl,
},
});
const cb = sinon.spy(async () => {
/* noop */
});
const result = await sendRequest(postData, 5, cb);
// fetch was called with correct URL and options
expect(stubs.fetchStub.calledOnce).to.be.true;
const [calledUrl, calledOpts] = stubs.fetchStub.getCall(0).args;
expect(calledUrl).to.equal(values.Livechat_webhookUrl);
expect(calledOpts).to.deep.include({
method: 'POST',
body: postData,
timeout: values.Livechat_http_timeout,
});
expect(calledOpts.headers).to.have.property('X-RocketChat-Livechat-Token', secret);
// success metrics and callback
expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.calledOnce).to.be.true;
expect(stubs.metrics.totalLivechatWebhooksFailures.inc.notCalled).to.be.true;
expect(cb.calledOnce).to.be.true;
// result is the fetch response
expect(result).to.be.ok;
expect(result!.status).to.equal(200);
// debug logging invoked at least once
expect(stubs.logger.debug.called).to.be.true;
expect(stubs.logger.error.notCalled).to.be.true;
});
it('omits X-RocketChat-Livechat-Token header when secret token is falsy', async () => {
const { sendRequest, stubs } = buildSubject({
fetchOnce: { status: 200, text: 'OK' },
settings: {
Livechat_secret_token: '',
},
});
await sendRequest({ type: 'NoSecret' });
const [, calledOpts] = stubs.fetchStub.getCall(0).args;
expect(calledOpts.headers).to.not.have.property('X-RocketChat-Livechat-Token');
});
it('logs and does not retry on non-retryable status (e.g., 400)', async () => {
const { sendRequest, stubs } = buildSubject({
fetchOnce: { status: 400, text: 'Bad Request' },
});
const cb = sinon.spy(async () => {
/* noop */
});
const result = await sendRequest({ type: 'NonRetryable' }, 5, cb);
// Does not throw; returns undefined
expect(result).to.be.undefined;
// fetch called only once, no retry
expect(stubs.fetchStub.calledOnce).to.be.true;
// failure metric incremented
expect(stubs.metrics.totalLivechatWebhooksFailures.inc.calledOnce).to.be.true;
expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.notCalled).to.be.true;
// callback not called on failure
expect(cb.notCalled).to.be.true;
// error log with status and response
expect(stubs.logger.error.called).to.be.true;
const logArg = stubs.logger.error.getCall(0).args[0];
expect(logArg).to.include.keys(['msg', 'status', 'response']);
expect(logArg.status).to.equal(400);
expect(logArg.response).to.equal('Bad Request');
});
it('retries once on retryable status (e.g., 500) and succeeds on next attempt', async () => {
clock = sinon.useFakeTimers();
const timeout = 1500;
const retryAfter = timeout * 4;
const { sendRequest, stubs } = buildSubject({
fetchSequence: [
{ status: 500, text: 'Server Error' },
{ status: 200, text: 'OK' },
],
settings: { Livechat_http_timeout: timeout },
});
const cb = sinon.spy(async () => {
/* noop */
});
// Call initial request; it will schedule a retry
await sendRequest({ type: 'RetryableOnce' }, 5, cb);
expect(stubs.fetchStub.callCount).to.equal(1);
expect(stubs.metrics.totalLivechatWebhooksFailures.inc.calledOnce).to.be.true;
// Advance clock to trigger the retry
// use tickAsync to ensure promise microtasks inside the timer are flushed
await clock.tickAsync(retryAfter);
// After retry, we should have a success
expect(stubs.fetchStub.callCount).to.equal(2);
expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.calledOnce).to.be.true;
// callback called once for the successful attempt
expect(cb.calledOnce).to.be.true;
// no terminal error log
const errorMsgs = stubs.logger.error.getCalls().map((c: sinon.SinonSpyCall) => c.args[0]?.msg);
expect(errorMsgs.some((m: string) => typeof m === 'string' && m.includes('Max attempts'))).to.be.false;
});
it('stops after max attempts and logs final error for repeated retryable failures', async () => {
clock = sinon.useFakeTimers();
const timeout = 500;
const retryAfter = timeout * 4;
// Prepare 5 retryable failures (equal to attempts)
const failures = Array.from({ length: 5 }, () => ({ status: 500, text: 'Upstream down' }));
const { sendRequest, stubs } = buildSubject({
fetchSequence: failures,
settings: { Livechat_http_timeout: timeout },
});
const postData = { type: 'AlwaysFail' };
// Fire the initial call
await sendRequest(postData, 5);
// Process the 4 scheduled retries
for (let i = 0; i < 4; i++) {
await clock.tickAsync(retryAfter);
}
// fetch was attempted 5 times total
expect(stubs.fetchStub.callCount).to.equal(5);
// failure metric incremented for each failed attempt
expect(stubs.metrics.totalLivechatWebhooksFailures.inc.callCount).to.equal(5);
expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.notCalled).to.be.true;
// terminal error logged when max attempts reached
expect(stubs.logger.error.called).to.be.true;
const msgs = stubs.logger.error.getCalls().map((c: sinon.SinonSpyCall) => c.args[0]?.msg);
expect(msgs.some((m: string) => typeof m === 'string' && m.includes('Max attempts'))).to.be.true;
});
it('passes the Response to the callback on success', async () => {
const responseText = 'Great Success';
const { sendRequest, stubs } = buildSubject({
fetchOnce: { status: 200, text: responseText },
});
const cb = sinon.spy(async (res: FetchResponse) => {
const txt = await res.text();
expect(txt).to.equal(responseText);
});
await sendRequest({ type: 'CbTest' }, 5, cb);
expect(cb.calledOnce).to.be.true;
expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.calledOnce).to.be.true;
});
it('uses default attempts=5 and schedules retry with delay = timeout*4', async () => {
clock = sinon.useFakeTimers();
const timeout = 1234;
const retryAfter = timeout * 4;
const { sendRequest, stubs } = buildSubject({
fetchSequence: [
{ status: 500, text: 'fail' },
{ status: 200, text: 'ok' },
],
settings: { Livechat_http_timeout: timeout },
});
await sendRequest({ type: 'DelayCheck' });
// Before advancing time, only first attempt should have been made
expect(stubs.fetchStub.callCount).to.equal(1);
// Advance just shy of the retryAfter - no retry should happen
await clock.tickAsync(retryAfter - 1);
expect(stubs.fetchStub.callCount).to.equal(1);
// Advance the remaining 1ms - retry should fire
await clock.tickAsync(1);
expect(stubs.fetchStub.callCount).to.equal(2);
});
});

View File

@@ -0,0 +1,208 @@
import { expect } from 'chai';
import { parseMessageSearchQuery } from '../../../../server/lib/parseMessageSearchQuery';
import { createFakeUser } from '../../../mocks/data';
describe('parseMessageSearchQuery', () => {
const params = {
user: createFakeUser(),
};
const utcOffset = new Date().getTimezoneOffset() / 60;
[
{
text: 'from:rodrigo mention:gabriel chat',
query: {
'u.username': { $regex: 'rodrigo', $options: 'i' },
'mentions.username': { $regex: 'gabriel', $options: 'i' },
'$text': { $search: 'chat' },
},
options: {
projection: { score: { $meta: 'textScore' } },
sort: { ts: -1 },
skip: 0,
limit: 20,
},
},
{
text: 'has:star',
query: { 'starred._id': params.user._id },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'has:url',
query: { 'urls.0': { $exists: true } },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'has:link',
query: { 'urls.0': { $exists: true } },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'is:pinned',
query: { pinned: true },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'has:pin',
query: { pinned: true },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'has:location',
query: { location: { $exists: true } },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'has:map',
query: { location: { $exists: true } },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'label:label',
query: { 'attachments.0.labels': { $regex: 'label', $options: 'i' } },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'label:label1 label:label2',
query: { 'attachments.0.labels': { $regex: 'label2', $options: 'i' } },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'file-title:title',
query: { 'attachments.title': { $regex: 'title', $options: 'i' } },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'file-desc:description',
query: {
'attachments.description': { $regex: 'description', $options: 'i' },
},
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'file-desc:"description"',
query: {
'attachments.description': { $regex: 'description', $options: 'i' },
},
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'file-title:"monthly report"',
query: {
'attachments.title': { $regex: 'monthly report', $options: 'i' },
},
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'file-desc:отчет follow-up needed',
query: {
'attachments.description': { $regex: 'отчет', $options: 'i' },
'$text': { $search: 'follow-up needed' },
},
options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'file-title:"🚀 launch plan" notes later',
query: {
'attachments.title': { $regex: '🚀 launch plan', $options: 'i' },
'$text': { $search: 'notes later' },
},
options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'notes later file-title:"🚀 launch plan"',
query: {
'attachments.title': { $regex: '🚀 launch plan', $options: 'i' },
'$text': { $search: 'notes later' },
},
options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'file-desc:report2024 file-title:Q1-review pending',
query: {
'attachments.description': { $regex: 'report2024', $options: 'i' },
'attachments.title': { $regex: 'Q1\\-review', $options: 'i' },
'$text': { $search: 'pending' },
},
options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'file-desc:"中 文 测 试" file-title:"报 表" 其他内容',
query: {
'attachments.description': { $regex: '中 文 测 试', $options: 'i' },
'attachments.title': { $regex: '报 表', $options: 'i' },
'$text': { $search: '其他内容' },
},
options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'file-desc:отчет file-title:"финансовый план" завершено',
query: {
'attachments.description': { $regex: 'отчет', $options: 'i' },
'attachments.title': { $regex: 'финансовый план', $options: 'i' },
'$text': { $search: 'завершено' },
},
options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'file-title:"🔥 final version" confirm now',
query: {
'attachments.title': { $regex: '🔥 final version', $options: 'i' },
'$text': { $search: 'confirm now' },
},
options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'file-desc:"launch" from:username new plans',
query: {
'attachments.description': { $regex: 'launch', $options: 'i' },
'u.username': { $regex: 'username', $options: 'i' },
'$text': { $search: 'new plans' },
},
options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'mention:someone file-desc:"budget 💰" file update',
query: {
'attachments.description': { $regex: 'budget 💰', $options: 'i' },
'mentions.username': { $regex: 'someone', $options: 'i' },
'$text': { $search: 'file update' },
},
options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'before:01-01-2023',
query: { ts: { $lte: new Date(2023, 0, 1, utcOffset) } },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'after:01-01-2023',
query: { ts: { $gte: new Date(2023, 0, 2, utcOffset) } },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'on:01-01-2023',
query: { ts: { $gte: new Date(2023, 0, 1, utcOffset), $lt: new Date(2023, 0, 2, utcOffset) } },
options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 },
},
{
text: 'order:asc',
query: {},
options: { projection: {}, sort: { ts: 1 }, skip: 0, limit: 20 },
},
{
text: 'hello world',
query: { $text: { $search: 'hello world' } },
options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 },
},
].forEach(({ text, query: expectedQuery, options: expectedOptions }) => {
it(`should parse ${JSON.stringify(text)}`, () => {
const { query, options } = parseMessageSearchQuery(text, params);
expect(query).to.deep.equal(expectedQuery);
expect(options).to.deep.equal(expectedOptions);
});
});
});

View File

@@ -0,0 +1,264 @@
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import p from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
Rooms: {
findOneById: sinon.stub(),
incUsersCountById: sinon.stub(),
},
Subscriptions: {
findOneByRoomIdAndUserId: sinon.stub(),
banByRoomIdAndUserId: sinon.stub(),
},
Users: {
removeRoomByUserId: sinon.stub(),
},
};
const messageMock = {
saveSystemMessage: sinon.stub(),
};
const removeUserFromRolesAsyncMock = sinon.stub();
const notifyOnRoomChangedByIdMock = sinon.stub();
const notifyOnSubscriptionChangedMock = sinon.stub();
const afterBanFromRoomCallbackMock = { run: sinon.stub() };
const meteorErrorMock = class extends Error {
constructor(message: string) {
super(message);
}
};
const { performUserBan, banUserFromRoom } = p.noCallThru().load('../../../../../server/lib/rooms/banUserFromRoom.ts', {
'@rocket.chat/models': modelsMock,
'@rocket.chat/core-services': { Message: messageMock },
'meteor/meteor': { Meteor: { Error: meteorErrorMock } },
'../callbacks/afterBanFromRoomCallback': { afterBanFromRoomCallback: afterBanFromRoomCallbackMock },
'../roles/removeUserFromRoles': { removeUserFromRolesAsync: removeUserFromRolesAsyncMock },
'../notifyListener': {
notifyOnRoomChangedById: notifyOnRoomChangedByIdMock,
notifyOnSubscriptionChanged: notifyOnSubscriptionChangedMock,
},
});
describe('banUserFromRoom', () => {
const mockByUser = { _id: 'admin1', username: 'admin' };
beforeEach(() => {
modelsMock.Rooms.findOneById.reset();
modelsMock.Rooms.incUsersCountById.reset();
modelsMock.Subscriptions.findOneByRoomIdAndUserId.reset();
modelsMock.Subscriptions.banByRoomIdAndUserId.reset();
modelsMock.Users.removeRoomByUserId.reset();
messageMock.saveSystemMessage.reset();
removeUserFromRolesAsyncMock.reset();
notifyOnRoomChangedByIdMock.reset();
notifyOnSubscriptionChangedMock.reset();
afterBanFromRoomCallbackMock.run.reset();
});
describe('performUserBan', () => {
it('should return early if no subscription exists', async () => {
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves(null);
const room = { _id: 'room1', t: 'c' };
const user = { _id: 'user1', username: 'testuser' };
await performUserBan(room, user, mockByUser);
expect(modelsMock.Subscriptions.banByRoomIdAndUserId.called).to.be.false;
expect(modelsMock.Users.removeRoomByUserId.called).to.be.false;
});
it('should throw if user has no username', async () => {
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
const room = { _id: 'room1', t: 'c' };
const user = { _id: 'user1' }; // no username
await expect(performUserBan(room, user, mockByUser)).to.be.rejectedWith('User must have a username to be banned from the room');
});
it('should return early if subscription is already BANNED', async () => {
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1', status: 'BANNED' });
const room = { _id: 'room1', t: 'c' };
const user = { _id: 'user1', username: 'testuser' };
await performUserBan(room, user, mockByUser);
expect(modelsMock.Subscriptions.banByRoomIdAndUserId.called).to.be.false;
});
it('should ban the user subscription', async () => {
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
modelsMock.Subscriptions.banByRoomIdAndUserId.resolves();
modelsMock.Users.removeRoomByUserId.resolves();
modelsMock.Rooms.incUsersCountById.resolves();
removeUserFromRolesAsyncMock.resolves();
messageMock.saveSystemMessage.resolves();
const room = { _id: 'room1', t: 'c' };
const user = { _id: 'user1', username: 'testuser' };
await performUserBan(room, user, mockByUser);
expect(modelsMock.Subscriptions.banByRoomIdAndUserId.calledWith('room1', 'user1')).to.be.true;
});
it('should remove the room from user __rooms array', async () => {
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
modelsMock.Subscriptions.banByRoomIdAndUserId.resolves();
modelsMock.Users.removeRoomByUserId.resolves();
modelsMock.Rooms.incUsersCountById.resolves();
removeUserFromRolesAsyncMock.resolves();
messageMock.saveSystemMessage.resolves();
const room = { _id: 'room1', t: 'c' };
const user = { _id: 'user1', username: 'testuser' };
await performUserBan(room, user, mockByUser);
expect(modelsMock.Users.removeRoomByUserId.calledWith('user1', 'room1')).to.be.true;
});
it('should decrement the room user count', async () => {
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
modelsMock.Subscriptions.banByRoomIdAndUserId.resolves();
modelsMock.Users.removeRoomByUserId.resolves();
modelsMock.Rooms.incUsersCountById.resolves();
removeUserFromRolesAsyncMock.resolves();
messageMock.saveSystemMessage.resolves();
const room = { _id: 'room1', t: 'c' };
const user = { _id: 'user1', username: 'testuser' };
await performUserBan(room, user, mockByUser);
expect(modelsMock.Rooms.incUsersCountById.calledWith('room1', -1)).to.be.true;
});
it('should remove room-scoped roles for channels', async () => {
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
modelsMock.Subscriptions.banByRoomIdAndUserId.resolves();
modelsMock.Users.removeRoomByUserId.resolves();
modelsMock.Rooms.incUsersCountById.resolves();
removeUserFromRolesAsyncMock.resolves();
messageMock.saveSystemMessage.resolves();
const room = { _id: 'room1', t: 'c' };
const user = { _id: 'user1', username: 'testuser' };
await performUserBan(room, user, mockByUser);
expect(removeUserFromRolesAsyncMock.calledWith('user1', ['moderator', 'owner', 'leader'], 'room1')).to.be.true;
});
it('should remove room-scoped roles for private rooms', async () => {
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
modelsMock.Subscriptions.banByRoomIdAndUserId.resolves();
modelsMock.Users.removeRoomByUserId.resolves();
modelsMock.Rooms.incUsersCountById.resolves();
removeUserFromRolesAsyncMock.resolves();
messageMock.saveSystemMessage.resolves();
const room = { _id: 'room1', t: 'p' };
const user = { _id: 'user1', username: 'testuser' };
await performUserBan(room, user, mockByUser);
expect(removeUserFromRolesAsyncMock.calledWith('user1', ['moderator', 'owner', 'leader'], 'room1')).to.be.true;
});
it('should NOT remove room-scoped roles for DMs', async () => {
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
modelsMock.Subscriptions.banByRoomIdAndUserId.resolves();
modelsMock.Users.removeRoomByUserId.resolves();
modelsMock.Rooms.incUsersCountById.resolves();
messageMock.saveSystemMessage.resolves();
const room = { _id: 'room1', t: 'd' };
const user = { _id: 'user1', username: 'testuser' };
await performUserBan(room, user, mockByUser);
expect(removeUserFromRolesAsyncMock.called).to.be.false;
});
it('should save system message including who banned', async () => {
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
modelsMock.Subscriptions.banByRoomIdAndUserId.resolves();
modelsMock.Users.removeRoomByUserId.resolves();
modelsMock.Rooms.incUsersCountById.resolves();
removeUserFromRolesAsyncMock.resolves();
messageMock.saveSystemMessage.resolves();
const room = { _id: 'room1', t: 'c' };
const user = { _id: 'user1', username: 'testuser' };
const byUser = { _id: 'moderator2', username: 'mod' };
await performUserBan(room, user, byUser);
expect(messageMock.saveSystemMessage.calledOnce).to.be.true;
const { args } = messageMock.saveSystemMessage.firstCall;
expect(args[0]).to.equal('user-banned');
expect(args[1]).to.equal('room1');
expect(args[2]).to.equal('testuser');
expect(args[4]).to.deep.include({ u: byUser });
});
it('should notify subscription as removed so client drops the stream', async () => {
const subscription = { _id: 'sub1', rid: 'room1', u: { _id: 'user1' } };
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves(subscription);
modelsMock.Subscriptions.banByRoomIdAndUserId.resolves();
modelsMock.Users.removeRoomByUserId.resolves();
modelsMock.Rooms.incUsersCountById.resolves();
removeUserFromRolesAsyncMock.resolves();
messageMock.saveSystemMessage.resolves();
const room = { _id: 'room1', t: 'c' };
const user = { _id: 'user1', username: 'testuser' };
await performUserBan(room, user, mockByUser);
expect(notifyOnSubscriptionChangedMock.calledWith(subscription, 'removed')).to.be.true;
expect(notifyOnRoomChangedByIdMock.calledWith('room1')).to.be.true;
});
});
describe('banUserFromRoom', () => {
it('should throw if room does not exist', async () => {
modelsMock.Rooms.findOneById.resolves(null);
const user = { _id: 'user1', username: 'testuser' };
await expect(banUserFromRoom('room1', user, mockByUser)).to.be.rejectedWith('error-invalid-room');
});
it('should call performUserBan with correct arguments', async () => {
const room = { _id: 'room1', t: 'c' };
modelsMock.Rooms.findOneById.resolves(room);
modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' });
modelsMock.Subscriptions.banByRoomIdAndUserId.resolves();
modelsMock.Users.removeRoomByUserId.resolves();
modelsMock.Rooms.incUsersCountById.resolves();
removeUserFromRolesAsyncMock.resolves();
messageMock.saveSystemMessage.resolves();
const user = { _id: 'user1', username: 'testuser' };
await banUserFromRoom('room1', user, mockByUser);
expect(modelsMock.Subscriptions.banByRoomIdAndUserId.calledWith('room1', 'user1')).to.be.true;
expect(afterBanFromRoomCallbackMock.run.calledOnce).to.be.true;
expect(afterBanFromRoomCallbackMock.run.firstCall.args[0]).to.deep.include({
bannedUser: user,
userWhoBanned: mockByUser,
});
expect(afterBanFromRoomCallbackMock.run.firstCall.args[1]).to.equal(room);
});
});
});

View File

@@ -0,0 +1,107 @@
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import Sinon from 'sinon';
const RoomsStub = {
findByTypeAndNameOrId: Sinon.stub(),
findOneDirectRoomContainingAllUserIDs: Sinon.stub(),
};
const UsersStub = {
findOneById: Sinon.stub(),
findUsersByUsernames: Sinon.stub(),
};
const { findDirectRoomByIdentifier } = proxyquire.noCallThru().load('../../../../../server/lib/rooms/findDirectRoomByIdentifier.ts', {
'@rocket.chat/models': { Rooms: RoomsStub, Users: UsersStub },
});
const cursorOf = (docs: unknown[]) => ({ toArray: async () => docs });
describe('findDirectRoomByIdentifier', () => {
beforeEach(() => {
RoomsStub.findByTypeAndNameOrId.reset();
RoomsStub.findOneDirectRoomContainingAllUserIDs.reset();
UsersStub.findOneById.reset();
UsersStub.findUsersByUsernames.reset();
RoomsStub.findByTypeAndNameOrId.resolves(null);
UsersStub.findOneById.resolves({ _id: 'me', username: 'me' });
});
it('should return the room when the identifier is a room id', async () => {
const room = { _id: 'rid1', t: 'd' };
RoomsStub.findByTypeAndNameOrId.resolves(room);
expect(await findDirectRoomByIdentifier('rid1', 'me')).to.equal(room);
expect(UsersStub.findUsersByUsernames.called).to.be.false;
});
it('should resolve a two-person DM by the other username, over sorted uids', async () => {
const room = { _id: 'rid1', t: 'd' };
UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'zeta' }, { _id: 'alpha' }]));
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(room);
expect(await findDirectRoomByIdentifier('alice', 'me')).to.equal(room);
expect(RoomsStub.findOneDirectRoomContainingAllUserIDs.calledWith(['alpha', 'zeta'])).to.be.true;
});
it('should include the caller in the member set exactly once for a self-DM', async () => {
UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }]));
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves({ _id: 'self', t: 'd' });
await findDirectRoomByIdentifier('me', 'me');
expect(UsersStub.findUsersByUsernames.firstCall.args[0]).to.deep.equal(['me']);
expect(RoomsStub.findOneDirectRoomContainingAllUserIDs.calledWith(['me'])).to.be.true;
});
it('should resolve a group DM from a comma-separated identifier', async () => {
UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }, { _id: 'a' }, { _id: 'b' }]));
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves({ _id: 'group', t: 'd' });
await findDirectRoomByIdentifier('a,b', 'me');
expect(UsersStub.findUsersByUsernames.firstCall.args[0]).to.deep.equal(['me', 'a', 'b']);
expect(RoomsStub.findOneDirectRoomContainingAllUserIDs.calledWith(['a', 'b', 'me'])).to.be.true;
});
it('should not look the identifier up as a room id when it is a username list', async () => {
UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }, { _id: 'a' }, { _id: 'b' }]));
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves({ _id: 'group', t: 'd' });
await findDirectRoomByIdentifier('a,b', 'me');
expect(RoomsStub.findByTypeAndNameOrId.called).to.be.false;
});
it('should trim whitespace around each username in the list', async () => {
UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }, { _id: 'a' }, { _id: 'b' }]));
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves({ _id: 'group', t: 'd' });
await findDirectRoomByIdentifier('a, b', 'me');
expect(UsersStub.findUsersByUsernames.firstCall.args[0]).to.deep.equal(['me', 'a', 'b']);
});
it('should return null when any username does not resolve to a user', async () => {
UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }]));
expect(await findDirectRoomByIdentifier('ghost', 'me')).to.be.null;
expect(RoomsStub.findOneDirectRoomContainingAllUserIDs.called).to.be.false;
});
it('should return null when no direct room contains that member set', async () => {
UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }, { _id: 'alice' }]));
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(null);
expect(await findDirectRoomByIdentifier('alice', 'me')).to.be.null;
});
it('should return null when the caller has no username', async () => {
UsersStub.findOneById.resolves({ _id: 'me' });
expect(await findDirectRoomByIdentifier('alice', 'me')).to.be.null;
});
});

View File

@@ -0,0 +1,518 @@
import { expect } from 'chai';
import { describe, it, beforeEach, afterEach } from 'mocha';
import proxyquire from 'proxyquire';
import Sinon from 'sinon';
type RoomType = 'c' | 'p' | 'd' | 'l';
interface IUser {
_id: string;
username: string;
federated?: boolean;
federation?: any;
}
interface IRoom {
_id: string;
t: RoomType;
name?: string;
}
type GetRoomByNameOrIdWithOptionToJoinFn = (params: {
user: Pick<IUser, '_id' | 'username' | 'federated' | 'federation'>;
nameOrId: string;
type?: RoomType;
tryDirectByUserIdOnly?: boolean;
joinChannel?: boolean;
errorOnEmpty?: boolean;
}) => Promise<IRoom | null>;
const RoomsStub = {
findOneByIdOrName: Sinon.stub(),
findOneDirectRoomContainingAllUserIDs: Sinon.stub(),
findOneById: Sinon.stub(),
};
const SubscriptionsStub = {
findOneByRoomIdAndUserId: Sinon.stub(),
};
const UsersStub = {
findOneById: Sinon.stub(),
findOne: Sinon.stub(),
};
const RoomServiceStub = {
join: Sinon.stub().resolves(),
};
const MeteorStub = {
Meteor: {
Error: Sinon.stub().callsFake(function (this: any, code: string) {
this.error = code;
this.errorType = 'Meteor.Error';
} as any),
},
};
const createDirectMessageStub = Sinon.stub().resolves({ rid: 'newDirectRoomId' });
const isObjectMock = {
isObject(obj: unknown) {
return obj !== null && typeof obj === 'object';
},
};
const {
getRoomByNameOrIdWithOptionToJoin,
}: {
getRoomByNameOrIdWithOptionToJoin: GetRoomByNameOrIdWithOptionToJoinFn;
} = proxyquire.noCallThru().load('../../../../../server/lib/rooms/getRoomByNameOrIdWithOptionToJoin.ts', {
'@rocket.chat/models': {
Rooms: RoomsStub,
Subscriptions: SubscriptionsStub,
Users: UsersStub,
},
'@rocket.chat/core-services': {
Room: RoomServiceStub,
},
'../../../lib/utils/isObject': isObjectMock,
'../../meteor-methods/messages/createDirectMessage': {
createDirectMessage: createDirectMessageStub,
},
'meteor/meteor': MeteorStub,
}) as any;
describe('getRoomByNameOrIdWithOptionToJoin', () => {
const baseUser: IUser = {
_id: 'userId',
username: 'user',
};
beforeEach(() => {
RoomsStub.findOneByIdOrName.reset();
RoomsStub.findOneDirectRoomContainingAllUserIDs.reset();
RoomsStub.findOneById.reset();
SubscriptionsStub.findOneByRoomIdAndUserId.reset();
UsersStub.findOneById.reset();
UsersStub.findOne.reset();
RoomServiceStub.join.reset();
RoomServiceStub.join.resolves();
MeteorStub.Meteor.Error.resetBehavior();
MeteorStub.Meteor.Error.resetHistory();
MeteorStub.Meteor.Error.callsFake(function (this: any, code: string) {
this.error = code;
this.errorType = 'Meteor.Error';
} as any);
createDirectMessageStub.reset();
createDirectMessageStub.resolves({ rid: 'newDirectRoomId' });
});
afterEach(() => {
Sinon.restore();
});
describe('channel / group resolution', () => {
it('should strip leading # and lookup by id or name', async () => {
const room: IRoom = { _id: 'room1', t: 'c', name: 'general' };
RoomsStub.findOneByIdOrName.resolves(room);
SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: '#general',
type: undefined,
tryDirectByUserIdOnly: false,
joinChannel: false,
errorOnEmpty: true,
});
expect(RoomsStub.findOneByIdOrName.calledOnceWithExactly('general')).to.equal(true);
expect(result).to.equal(room);
});
it('should treat name without # as channel/group and call findOneByIdOrName', async () => {
const room: IRoom = { _id: 'room2', t: 'c', name: 'random' };
RoomsStub.findOneByIdOrName.resolves(room);
SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'random',
type: undefined,
tryDirectByUserIdOnly: false,
joinChannel: false,
errorOnEmpty: true,
});
expect(RoomsStub.findOneByIdOrName.calledOnceWithExactly('random')).to.equal(true);
expect(result).to.equal(room);
});
it('should throw Meteor.Error("invalid-channel") when channel not found and errorOnEmpty=true', async () => {
RoomsStub.findOneByIdOrName.resolves(null);
let caught: any;
try {
await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'nonexistent',
type: undefined,
tryDirectByUserIdOnly: false,
joinChannel: false,
errorOnEmpty: true,
});
} catch (e) {
caught = e;
}
expect(caught).to.be.an('object');
expect(caught.error).to.equal('invalid-channel');
});
it('should return null when channel not found and errorOnEmpty=false', async () => {
RoomsStub.findOneByIdOrName.resolves(null);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'nonexistent',
type: undefined,
tryDirectByUserIdOnly: false,
joinChannel: false,
errorOnEmpty: false,
});
expect(result).to.equal(null);
});
});
describe('type filtering', () => {
it('should return room when type matches', async () => {
const room: IRoom = { _id: 'room3', t: 'c', name: 'dev' };
RoomsStub.findOneByIdOrName.resolves(room);
SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'dev',
type: 'c',
tryDirectByUserIdOnly: false,
joinChannel: false,
errorOnEmpty: true,
});
expect(result).to.equal(room);
});
it('should throw Meteor.Error when type does not match and errorOnEmpty=true', async () => {
const room: IRoom = { _id: 'room4', t: 'p', name: 'private' };
RoomsStub.findOneByIdOrName.resolves(room);
SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null);
let caught: any;
try {
await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'private',
type: 'c',
tryDirectByUserIdOnly: false,
joinChannel: false,
errorOnEmpty: true,
});
} catch (e) {
caught = e;
}
expect(caught).to.be.an('object');
expect(caught.error).to.equal('invalid-channel');
});
it('should return null when type does not match and errorOnEmpty=false', async () => {
const room: IRoom = { _id: 'room5', t: 'p', name: 'private' };
RoomsStub.findOneByIdOrName.resolves(room);
SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'private',
type: 'c',
tryDirectByUserIdOnly: false,
joinChannel: false,
errorOnEmpty: false,
});
expect(result).to.equal(null);
});
});
describe('joining channel behaviour', () => {
it('should call Room.join when room is channel, joinChannel=true, and user not subscribed', async () => {
const room: IRoom = { _id: 'room6', t: 'c', name: 'joinable' };
RoomsStub.findOneByIdOrName.resolves(room);
SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'joinable',
type: 'c',
tryDirectByUserIdOnly: false,
joinChannel: true,
errorOnEmpty: true,
});
expect(SubscriptionsStub.findOneByRoomIdAndUserId.calledOnceWithExactly('room6', baseUser._id, { projection: { _id: 1 } })).to.equal(
true,
);
expect(
RoomServiceStub.join.calledOnceWithExactly({
room,
user: baseUser,
}),
).to.equal(true);
expect(result).to.equal(room);
});
it('should not call Room.join when joinChannel=false', async () => {
const room: IRoom = { _id: 'room7', t: 'c', name: 'nojoin' };
RoomsStub.findOneByIdOrName.resolves(room);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'nojoin',
type: 'c',
tryDirectByUserIdOnly: false,
joinChannel: false,
errorOnEmpty: true,
});
expect(SubscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.equal(true);
expect(RoomServiceStub.join.notCalled).to.equal(true);
expect(result).to.equal(room);
});
it('should not call Room.join when room type is not channel', async () => {
const room: IRoom = { _id: 'room8', t: 'p', name: 'private' };
RoomsStub.findOneByIdOrName.resolves(room);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'private',
type: 'p',
tryDirectByUserIdOnly: false,
joinChannel: true,
errorOnEmpty: true,
});
expect(SubscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.equal(true);
expect(RoomServiceStub.join.notCalled).to.equal(true);
expect(result).to.equal(room);
});
it('should not call Room.join when user already subscribed', async () => {
const room: IRoom = { _id: 'room9', t: 'c', name: 'joined' };
RoomsStub.findOneByIdOrName.resolves(room);
SubscriptionsStub.findOneByRoomIdAndUserId.resolves({ _id: 'subId' });
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'joined',
type: 'c',
tryDirectByUserIdOnly: false,
joinChannel: true,
errorOnEmpty: true,
});
expect(SubscriptionsStub.findOneByRoomIdAndUserId.calledOnceWithExactly('room9', baseUser._id, { projection: { _id: 1 } })).to.equal(
true,
);
expect(RoomServiceStub.join.notCalled).to.equal(true);
expect(result).to.equal(room);
});
});
describe('direct message resolution by username', () => {
it('should find direct room with existing roomUser and existing DM room', async () => {
const otherUser: IUser = { _id: 'otherId', username: 'other' };
UsersStub.findOne.resolves(otherUser);
const dmRoom: IRoom = { _id: 'dm1', t: 'd' };
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: '@other',
type: 'd',
tryDirectByUserIdOnly: false,
joinChannel: true,
errorOnEmpty: true,
});
expect(
UsersStub.findOne.calledOnceWithExactly({
$or: [{ _id: 'other' }, { username: 'other' }],
}),
).to.equal(true);
expect(RoomsStub.findOneDirectRoomContainingAllUserIDs.calledOnce).to.equal(true);
const callArgs = RoomsStub.findOneDirectRoomContainingAllUserIDs.getCall(0).args[0];
expect(callArgs).to.be.an('array');
expect(callArgs.sort()).to.deep.equal(['otherId', 'userId'].sort());
expect(result).to.equal(dmRoom);
});
it('should strip leading @ when resolving DM', async () => {
const otherUser: IUser = { _id: 'otherId2', username: 'other2' };
UsersStub.findOne.resolves(otherUser);
const dmRoom: IRoom = { _id: 'dm2', t: 'd' };
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: '@other2',
type: undefined,
tryDirectByUserIdOnly: false,
joinChannel: true,
errorOnEmpty: true,
});
expect(
UsersStub.findOne.calledOnceWithExactly({
$or: [{ _id: 'other2' }, { username: 'other2' }],
}),
).to.equal(true);
expect(result).to.equal(dmRoom);
});
it('should treat nameOrId as DM when type is d even without @', async () => {
const otherUser: IUser = { _id: 'otherId3', username: 'plain' };
UsersStub.findOne.resolves(otherUser);
const dmRoom: IRoom = { _id: 'dm3', t: 'd' };
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'plain',
type: 'd',
tryDirectByUserIdOnly: false,
joinChannel: true,
errorOnEmpty: true,
});
expect(
UsersStub.findOne.calledOnceWithExactly({
$or: [{ _id: 'plain' }, { username: 'plain' }],
}),
).to.equal(true);
expect(result).to.equal(dmRoom);
});
});
describe('direct message resolution with tryDirectByUserIdOnly', () => {
it('should lookup user by id only when tryDirectByUserIdOnly=true', async () => {
const otherUser: IUser = { _id: 'idOnly', username: 'ignored' };
UsersStub.findOneById.resolves(otherUser);
const dmRoom: IRoom = { _id: 'dm4', t: 'd' };
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'idOnly',
type: 'd',
tryDirectByUserIdOnly: true,
joinChannel: true,
errorOnEmpty: true,
});
expect(UsersStub.findOneById.calledOnceWithExactly('idOnly')).to.equal(true);
expect(UsersStub.findOne.notCalled).to.equal(true);
expect(result).to.equal(dmRoom);
});
it('should fallback to Rooms.findOneById when roomUser is falsy but room exists by id', async () => {
UsersStub.findOneById.resolves(null);
const dmRoom: IRoom = { _id: 'idRoom', t: 'd' };
RoomsStub.findOneById.resolves(dmRoom);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: 'idRoom',
type: 'd',
tryDirectByUserIdOnly: true,
joinChannel: true,
errorOnEmpty: true,
});
expect(RoomsStub.findOneById.calledOnceWithExactly('idRoom')).to.equal(true);
expect(result).to.equal(dmRoom);
});
});
describe('direct message creation when roomUser exists but room does not', () => {
it('should throw Meteor.Error when roomUser not found and errorOnEmpty=true', async () => {
UsersStub.findOne.resolves(null);
RoomsStub.findOneById.resolves(null);
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(null);
let caught: any;
try {
await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: '@missing',
type: 'd',
tryDirectByUserIdOnly: false,
joinChannel: true,
errorOnEmpty: true,
});
} catch (e) {
caught = e;
}
expect(caught).to.be.an('object');
expect(caught.error).to.equal('invalid-channel');
expect(createDirectMessageStub.notCalled).to.equal(true);
});
it('should return null when roomUser not found and errorOnEmpty=false', async () => {
UsersStub.findOne.resolves(null);
RoomsStub.findOneById.resolves(null);
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(null);
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: '@missing',
type: 'd',
tryDirectByUserIdOnly: false,
joinChannel: true,
errorOnEmpty: false,
});
expect(result).to.equal(null);
expect(createDirectMessageStub.notCalled).to.equal(true);
});
it('should create a new direct message when roomUser exists and room is not found', async () => {
const otherUser: IUser = { _id: 'otherCreate', username: 'createMe' };
UsersStub.findOne.resolves(otherUser);
RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(null);
RoomsStub.findOneById.resolves({ _id: 'newDirectRoomId', t: 'd' });
const result = await getRoomByNameOrIdWithOptionToJoin({
user: baseUser,
nameOrId: '@createMe',
type: 'd',
tryDirectByUserIdOnly: false,
joinChannel: true,
errorOnEmpty: true,
});
expect(createDirectMessageStub.calledOnceWithExactly(['createMe'], baseUser._id)).to.equal(true);
expect(RoomsStub.findOneById.calledWith('newDirectRoomId')).to.equal(true);
expect(result).to.deep.equal({ _id: 'newDirectRoomId', t: 'd' });
});
});
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,43 @@
import type { SAMLDocumentType, SAMLPOSTEnvelope, SAMLRedirectEnvelope } from '../../../../../server/lib/saml/definition/SAMLEnvelope';
export function makeLogoutEnvelope<T extends SAMLDocumentType>(
type: T,
xml: string,
signedContent?: string,
signature?: string,
): SAMLRedirectEnvelope<T> {
return {
binding: 'HTTP-Redirect',
encodedDocument: xml,
decodedDocument: xml,
type,
relayState: 'relayState',
...(signedContent && { signedContent }),
...(signature && {
signature,
sigAlg: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
}),
};
}
export function makeLogoutRequestEnvelope(xml: string, signedContent?: string, signature?: string) {
return makeLogoutEnvelope('SAMLRequest', xml, signedContent, signature);
}
export function makeLogoutResponseEnvelope(xml: string, signedContent?: string, signature?: string) {
return makeLogoutEnvelope('SAMLResponse', xml, signedContent, signature);
}
export function makeLoginEnvelope<T extends SAMLDocumentType>(type: T, xml: string): SAMLPOSTEnvelope<T> {
return {
binding: 'HTTP-POST',
encodedDocument: xml,
decodedDocument: xml,
type,
relayState: 'relayState',
};
}
export function makeLoginResponseEnvelope(xml: string) {
return makeLoginEnvelope('SAMLResponse', xml);
}

View File

@@ -0,0 +1,225 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { InvalidIdpMetadataError, parseIdpMetadata } from '../../../../../server/lib/saml/lib/parsers/IdpMetadata';
// Real self-signed cert (CN=idp.test) — the parser validates X.509, so fixtures must be real certs.
const TEST_CERT =
'MIIDBzCCAe+gAwIBAgIUZhaSm8CbG7FmgCQ2wi7+HLFQMokwDQYJKoZIhvcNAQELBQAwEzERMA8GA1UEAwwIaWRwLnRlc3QwHhcNMjYwNzE4MTk1NzIwWhcNMzYwNzE1MTk1NzIwWjATMREwDwYDVQQDDAhpZHAudGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALbr9h69QN4dqnBTjnV7lWotx4FN2cktfL0Y2qVwQjw3q+OylaKCzio7Kvp2V2sQDsKDNgJnsLLP5TNjWqkXCfbbXRP/Iz3xlyyOoLNYJrZA4Sqn9/dFy6Chq5FSrMTwzqPCxx3nVDy/EpGUMknG7p3B0Ix18YFxQLsN5a/MpZXslrCusdl2LLnYkp6ztp44ZZlXHIaQhzeGnAZqzshvARAY9Ur41h4nSzpCgKVGACSi4LWRJeLc8/IXF+JM2MOR4GInCQLb1z31QPRRZ+3yWH3vKIfZ5YkPF6T0uPYw3hmhe0p7ECdOcSfckyeNT0WnvT8WKmeuOAnmmfqYPtgGBrkCAwEAAaNTMFEwHQYDVR0OBBYEFKQ/LDp2SiRg1jnOLLn/EkunXgaSMB8GA1UdIwQYMBaAFKQ/LDp2SiRg1jnOLLn/EkunXgaSMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBACGBYnhbQXkSTerNUN0Wu2sy1MfFUzMYTP3SaXaCMJQhlZXUxtnu1PYTuqCgNkgaiV6b/KUBLVkqX4BaDmP0O5SI3di/uYlAJsBUsDCdaXToKsnjpf9tMvci2TGmPMZMDx9Jbr37G0NR9vewTOFvTkcjBQoXVu5oFbr75EDmxu3hqe8KHiavnX8C57zzpZ8kn37ScP+0Zadu0VYCtKEzfNKp48rCOF3BtYugGcxQWdcvbqurNF9Fyk9laSl8cTLHFeDe6zdWig32n3nHzhANOcezQ/wsUY5XUfUpRUl90rend7zqNq0tFZzOiZDl1MjCs7HCYtTRKEkuwlxgXdvP5Tg=';
// Second distinct self-signed cert (CN=idp2.test), generated the same way, used to prove
// document-order-first selection among multiple KeyDescriptors.
const TEST_CERT_2 =
'MIIDCTCCAfGgAwIBAgIUS4xN3PfQV0ROnf5nSMMFrN90pmQwDQYJKoZIhvcNAQELBQAwFDESMBAGA1UEAwwJaWRwMi50ZXN0MB4XDTI2MDcxODIwMTAzN1oXDTM2MDcxNTIwMTAzN1owFDESMBAGA1UEAwwJaWRwMi50ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvAdfR0s3uE/Rnv59RRTwNRuYVTgJoBMoP+xxMhlm8FfZZTsBqv3vt2eqyLaxnChpS+UWnpf56zZpFrNgEAH+dEFPIkhqRL7nUmKSOCXu3nCTS5K03PEWJzbWcjgtbZ6A7Q7BE9bRmBqkkbUi22XQML06A2GIzPE8xliVhnYH5q4GRNIYf6Eh0eV88XZrsXvYgHC46/O/MUMLV5a6tCq2SPvbBKJSI01YQcE/W/C7NtiHUnmkpvWFrJ7Wn2akAwVeA8rL5SJ2rMbO1CB9JgaspjOGdkLqRc2LsZDO2Fmsk1BS6EGaEnxFIggq/LwQmdeu0nK8bmwLZOAB1MgGXbBq/wIDAQABo1MwUTAdBgNVHQ4EFgQUfgd/rj1KGMuiBR7byOQU0zl/bAMwHwYDVR0jBBgwFoAUfgd/rj1KGMuiBR7byOQU0zl/bAMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGxdEIVNeoN+BV82FbNGmFPXiQRO1FZk0pUqPczQ5TbN8XvTFWfY2YeawgVn0ec6+eA1M97znDAM5KPF+9RHdk5HRrjS+BX+uJcFKlbWEbZJShk4PookxLq6ELZQ5HPSGgGvyqSueeAI0RMg6aBcZOIzsVJduSeOQNAmkvppN6rTryFVBHvkyI9qYLu9bxZW/BvyUfXmvU+yPRDa98s/WzQowIKktNPTCoxwn6KfGLlmeH5nm9ra48aRKFCXuSVhiuph3lmD4IfbUDYijpfe3kLsmp1Up9bhVH7WUiyT/sD2NFBZp60UAohjWGolNXhosrUOELBiZVuR75gvLmLFV5Q==';
const REDIRECT = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect';
const POST = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST';
const keyDescriptor = (cert: string, use = 'signing') => `
<KeyDescriptor${use ? ` use="${use}"` : ''}>
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data><ds:X509Certificate>${cert}</ds:X509Certificate></ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>`;
const metadata = ({
keys = keyDescriptor(TEST_CERT),
slo = `<SingleLogoutService Binding="${REDIRECT}" Location="https://idp.test/slo"/>`,
sso = `<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso"/>`,
nameIdFormats = '',
} = {}) => `<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://idp.test/metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
${keys}
${nameIdFormats}
${slo}
${sso}
</IDPSSODescriptor>
</EntityDescriptor>`;
describe('parseIdpMetadata', () => {
it('extracts cert, entry point and SLO url from typical metadata', () => {
const result = parseIdpMetadata(metadata());
expect(result.cert).to.be.a('string').and.to.include(TEST_CERT.substring(0, 40));
expect(result.entryPoint).to.equal('https://idp.test/sso');
expect(result.idpSLORedirectURL).to.equal('https://idp.test/slo');
expect(result.warnings).to.deep.equal([]);
});
it('omits the SLO url without warning when metadata has no SingleLogoutService', () => {
const result = parseIdpMetadata(metadata({ slo: '' }));
expect(result.idpSLORedirectURL).to.be.undefined;
expect(result.entryPoint).to.equal('https://idp.test/sso');
expect(result.warnings).to.not.include('SAML_Metadata_warning_no_slo_redirect_binding');
});
it('uses the first signing cert (document order) and warns when there are multiple', () => {
const result = parseIdpMetadata(metadata({ keys: keyDescriptor(TEST_CERT) + keyDescriptor(TEST_CERT_2) }));
expect(result.cert).to.be.a('string').and.to.include(TEST_CERT.substring(0, 40));
expect(result.cert).to.not.include(TEST_CERT_2.substring(0, 40));
expect(result.warnings).to.include('SAML_Metadata_warning_multiple_certs');
});
it('accepts a KeyDescriptor without a use attribute', () => {
const result = parseIdpMetadata(metadata({ keys: keyDescriptor(TEST_CERT, '') }));
expect(result.cert).to.be.a('string');
});
it('skips a KeyDescriptor whose content is not a valid X.509 certificate and warns', () => {
const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') }));
expect(result.cert).to.be.undefined;
expect(result.warnings).to.include('SAML_Metadata_warning_no_valid_cert');
});
it('does not warn about multiple certs when only one of them is valid', () => {
const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') + keyDescriptor(TEST_CERT_2) }));
expect(result.cert).to.be.a('string').and.to.include(TEST_CERT_2.substring(0, 40));
expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_certs');
expect(result.warnings).to.not.include('SAML_Metadata_warning_no_valid_cert');
});
it('warns that no valid cert was found when every KeyDescriptor is invalid', () => {
const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') + keyDescriptor('bm90IGEgY2VydA==') }));
expect(result.cert).to.be.undefined;
expect(result.warnings).to.include('SAML_Metadata_warning_no_valid_cert');
expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_certs');
});
it('omits the entry point when only HTTP-POST SSO bindings exist', () => {
const result = parseIdpMetadata(metadata({ sso: `<SingleSignOnService Binding="${POST}" Location="https://idp.test/sso-post"/>` }));
expect(result.entryPoint).to.be.undefined;
expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding');
});
it('skips a Redirect SSO service with an invalid Location and uses the next valid one', () => {
const sso = `
<SingleSignOnService Binding="${REDIRECT}" Location="not a url"/>
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso-2"/>`;
const result = parseIdpMetadata(metadata({ sso }));
expect(result.entryPoint).to.equal('https://idp.test/sso-2');
expect(result.warnings).to.not.include('SAML_Metadata_warning_no_redirect_binding');
});
it('omits the entry point and warns when the only Redirect SSO Location is not http(s)', () => {
const sso = `<SingleSignOnService Binding="${REDIRECT}" Location="javascript:alert(1)"/>`;
const result = parseIdpMetadata(metadata({ sso }));
expect(result.entryPoint).to.be.undefined;
expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding');
});
it('omits the SLO url and warns when no SingleLogoutService uses the HTTP-Redirect binding', () => {
const slo = `<SingleLogoutService Binding="${POST}" Location="https://idp.test/slo-post"/>`;
const result = parseIdpMetadata(metadata({ slo }));
expect(result.idpSLORedirectURL).to.be.undefined;
expect(result.warnings).to.include('SAML_Metadata_warning_no_slo_redirect_binding');
});
it('picks the Redirect SingleLogoutService even when a POST one comes first', () => {
const slo = `
<SingleLogoutService Binding="${POST}" Location="https://idp.test/slo-post"/>
<SingleLogoutService Binding="${REDIRECT}" Location="https://idp.test/slo-redirect"/>`;
const result = parseIdpMetadata(metadata({ slo }));
expect(result.idpSLORedirectURL).to.equal('https://idp.test/slo-redirect');
expect(result.warnings).to.not.include('SAML_Metadata_warning_no_slo_redirect_binding');
});
it('ignores services nested under Extensions instead of declared directly on the descriptor', () => {
const sso = `
<Extensions>
<SingleSignOnService Binding="${REDIRECT}" Location="https://attacker.test/sso"/>
<SingleLogoutService Binding="${REDIRECT}" Location="https://attacker.test/slo"/>
</Extensions>
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso"/>`;
const result = parseIdpMetadata(metadata({ sso, slo: '' }));
expect(result.entryPoint).to.equal('https://idp.test/sso');
expect(result.idpSLORedirectURL).to.be.undefined;
});
it('omits the entry point and warns when there is no SingleSignOnService element at all', () => {
const result = parseIdpMetadata(metadata({ sso: '' }));
expect(result.entryPoint).to.be.undefined;
expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding');
});
it('extracts the NameIDFormat', () => {
const result = parseIdpMetadata(
metadata({ nameIdFormats: '<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress</NameIDFormat>' }),
);
expect(result.identifierFormat).to.equal('urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress');
expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_nameid_formats');
});
it('uses the first NameIDFormat (document order) and warns when there are multiple', () => {
const nameIdFormats = `
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress</NameIDFormat>
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>`;
const result = parseIdpMetadata(metadata({ nameIdFormats }));
expect(result.identifierFormat).to.equal('urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress');
expect(result.warnings).to.include('SAML_Metadata_warning_multiple_nameid_formats');
});
it('picks the SAML 2.0 role when another protocol comes first', () => {
const mixedRoles = `<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://mixed.test">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol">
<SingleSignOnService Binding="urn:mace:shibboleth:1.0:profiles:AuthnRequest" Location="https://mixed.test/saml1/sso"/>
</IDPSSODescriptor>
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol urn:oasis:names:tc:SAML:2.0:protocol">
<SingleSignOnService Binding="${REDIRECT}" Location="https://mixed.test/saml2/sso"/>
</IDPSSODescriptor>
</EntityDescriptor>`;
const result = parseIdpMetadata(mixedRoles);
expect(result.entryPoint).to.equal('https://mixed.test/saml2/sso');
});
it('rejects metadata whose only IDPSSODescriptor does not support SAML 2.0', () => {
const saml1Only = `<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://saml1.test">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol">
<SingleSignOnService Binding="${REDIRECT}" Location="https://saml1.test/sso"/>
</IDPSSODescriptor>
</EntityDescriptor>`;
expect(() => parseIdpMetadata(saml1Only)).to.throw(InvalidIdpMetadataError);
});
it('rejects SP-only metadata (no IDPSSODescriptor)', () => {
const spOnly = `<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://sp.test">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
</EntityDescriptor>`;
expect(() => parseIdpMetadata(spOnly)).to.throw(InvalidIdpMetadataError);
});
it('rejects a document whose root is not EntityDescriptor', () => {
expect(() => parseIdpMetadata('<html><body>nope</body></html>')).to.throw(InvalidIdpMetadataError);
});
it('rejects a federation aggregate wrapping EntityDescriptors', () => {
const aggregate = `<?xml version="1.0"?>
<EntitiesDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<EntityDescriptor entityID="https://idp.test/metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso"/>
</IDPSSODescriptor>
</EntityDescriptor>
</EntitiesDescriptor>`;
expect(() => parseIdpMetadata(aggregate)).to.throw(InvalidIdpMetadataError);
});
it('rejects an otherwise-valid document with an unclosed inner element', () => {
// xmldom auto-closes the unclosed <ds:KeyInfo> into a structurally-complete document (every guard
// passes), so this only throws because the parser treats the `warning` it emits as fatal.
const unclosedInner = `<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://idp.test/metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data><ds:X509Certificate>${TEST_CERT}</ds:X509Certificate></ds:X509Data>
</KeyDescriptor>
<SingleLogoutService Binding="${REDIRECT}" Location="https://idp.test/slo"/>
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso"/>
</IDPSSODescriptor>
</EntityDescriptor>`;
expect(() => parseIdpMetadata(unclosedInner)).to.throw(InvalidIdpMetadataError);
});
});

View File

@@ -0,0 +1,74 @@
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const retrieveCredential = sinon.stub().resolves(null);
const removeById = sinon.stub().resolves();
const samlUtilsMock = {
serviceProviders: [{ provider: 'test-saml' }] as any[],
log: sinon.stub(),
mapProfileToUserObject: sinon.stub(),
events: { emit: sinon.stub() },
};
const handler = sinon.stub();
proxyquire.noCallThru().load('../../../../../server/lib/saml/loginHandler', {
'@rocket.chat/models': {
CredentialTokens: { removeById },
},
'meteor/accounts-base': {
Accounts: {
LoginCancelledError: { numericError: 403 },
registerLoginHandler: (_name: string, fn: any) => {
handler.callsFake(fn);
},
},
},
'meteor/meteor': {
Meteor: { Error },
},
'./lib/SAML': {
SAML: { retrieveCredential },
},
'./lib/Utils': {
SAMLUtils: samlUtilsMock,
},
'../i18n': { i18n: { t: sinon.stub().returns('') } },
'../logger/system': { SystemLogger: { error: sinon.stub() } },
});
describe('SAML loginHandler', () => {
beforeEach(() => {
retrieveCredential.reset();
retrieveCredential.resolves(null);
removeById.reset();
removeById.resolves();
samlUtilsMock.serviceProviders = [{ provider: 'test-saml' }];
});
it('should reject non-string credentialToken and never query the database (NoSQL injection prevention)', async () => {
expect(await handler({ saml: true, credentialToken: { $gt: '' } })).to.be.undefined;
expect(await handler({ saml: true, credentialToken: { $ne: null } })).to.be.undefined;
expect(await handler({ saml: true, credentialToken: { $not: { $eq: '__nonexistent__' } } })).to.be.undefined;
expect(await handler({ saml: true, credentialToken: 123 })).to.be.undefined;
expect(await handler({ saml: true, credentialToken: ['a'] })).to.be.undefined;
expect(await handler({ saml: true, credentialToken: null })).to.be.undefined;
expect(retrieveCredential.called).to.be.false;
});
it('should return undefined when no SAML providers are configured', async () => {
samlUtilsMock.serviceProviders = [];
expect(await handler({ saml: true, credentialToken: 'valid-token' })).to.be.undefined;
expect(retrieveCredential.called).to.be.false;
});
it('should delete the credential token after retrieval', async () => {
await handler({ saml: true, credentialToken: 'token-to-delete' });
expect(removeById.calledOnce).to.be.true;
expect(removeById.calledWith('token-to-delete')).to.be.true;
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,197 @@
import { expect } from 'chai';
import p from 'proxyquire';
import sinon from 'sinon';
import { registerSettingSchema } from '../../../../server/settings/functions/settingSchemas';
import {
positiveOrDisabled,
notGreaterThanSetting,
notLowerThanSetting,
} from '../../../../server/settings/functions/validationRuleBuilders';
const settingsGetMock = sinon.stub();
const settingsGetSettingMock = sinon.stub();
const { validateSettingRules, SettingValidationError } = p.noCallThru().load('../../../../server/lib/settingValidationRules.ts', {
'@rocket.chat/logger': {
Logger: class {
error = (): undefined => undefined;
},
},
'../settings': { settings: { get: settingsGetMock, getSetting: settingsGetSettingMock } },
});
const validationBySettingId: Record<string, unknown> = {
Accounts_Password_Policy_MinLength: [positiveOrDisabled(), notGreaterThanSetting('Accounts_Password_Policy_MaxLength')],
Accounts_Password_Policy_MaxLength: [positiveOrDisabled(), notLowerThanSetting('Accounts_Password_Policy_MinLength')],
};
describe('validateSettingRules', () => {
beforeEach(() => {
settingsGetMock.reset();
settingsGetSettingMock.reset();
settingsGetSettingMock.callsFake((_id: string) => ({
_id,
type: 'int',
...(validationBySettingId[_id] ? { validation: JSON.stringify(validationBySettingId[_id]) } : {}),
}));
});
it('rejects an incoherent batch with the failed rule i18n key as the error message', () => {
const error = (() => {
try {
validateSettingRules([
{ _id: 'Accounts_Password_Policy_MinLength', value: 6 },
{ _id: 'Accounts_Password_Policy_MaxLength', value: 4 },
]);
} catch (thrown) {
return thrown as Error;
}
})();
expect(error).to.be.instanceOf(SettingValidationError);
expect(error?.message).to.equal('Accounts_Password_Policy_MinLength_Invalid');
});
it('resolves values batch-first, not from the stale cached value', () => {
settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(14);
expect(() =>
validateSettingRules([
{ _id: 'Accounts_Password_Policy_MinLength', value: 6 },
{ _id: 'Accounts_Password_Policy_MaxLength', value: 10 },
]),
).to.not.throw();
});
it('rejects raising the minimum above the stored maximum, with the minimum-side message', () => {
settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(10);
expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: 12 }])).to.throw(
'Accounts_Password_Policy_MinLength_Invalid',
);
});
it('rejects lowering the maximum below the stored minimum, with the maximum-side message', () => {
settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6);
expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: 4 }])).to.throw(
'Accounts_Password_Policy_MaxLength_Invalid',
);
});
it('treats exactly -1 as a disabled bound, on either side', () => {
settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(-1);
expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: 20 }])).to.not.throw();
settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(-1);
expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: 5 }])).to.not.throw();
settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6);
expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: -1 }])).to.not.throw();
});
it('rejects bound values below 1 that are not exactly -1', () => {
settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6);
expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: 0 }])).to.throw(
'Accounts_Password_Policy_MaxLength_Invalid',
);
expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: -5 }])).to.throw(
'Accounts_Password_Policy_MinLength_Invalid',
);
});
it('validates the pair even when the same batch disables the policy', () => {
expect(() =>
validateSettingRules([
{ _id: 'Accounts_Password_Policy_Enabled', value: false },
{ _id: 'Accounts_Password_Policy_MinLength', value: 6 },
{ _id: 'Accounts_Password_Policy_MaxLength', value: 4 },
]),
).to.throw('Accounts_Password_Policy_MinLength_Invalid');
});
it('does not evaluate rules of settings that are not in the batch', () => {
settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6);
settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(4);
expect(() => validateSettingRules([{ _id: 'Some_Unrelated_Setting', value: 10 }])).to.not.throw();
});
it('passes a rule referencing a setting that does not exist', () => {
settingsGetSettingMock.withArgs('Ref_Setting').returns({
_id: 'Ref_Setting',
type: 'int',
validation: '[{"query":{"value":{"$lte":{"$setting":"Missing_Setting"}}}}]',
});
expect(() => validateSettingRules([{ _id: 'Ref_Setting', value: 999 }])).to.not.throw();
});
it('skips malformed persisted rules instead of crashing the save', () => {
const malformed = [
'[{"query":null}]',
'[{"query":{"value":{"$gte":1}},"appliesWhen":"junk"}]',
'[{"query":{"value":{"$gte":1}},"appliesWhen":{"_id":"Gate"}}]',
];
for (const validation of malformed) {
settingsGetSettingMock.withArgs('Malformed_Setting').returns({ _id: 'Malformed_Setting', type: 'int', validation });
expect(() => validateSettingRules([{ _id: 'Malformed_Setting', value: 0 }]), validation).to.not.throw();
}
});
it('does not enforce the value type, leaving those checks to each save path', () => {
settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(10);
expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: 5.5 }])).to.not.throw();
});
describe('JSON settings with a schema', () => {
const schema = {
type: 'object',
required: ['version'],
additionalProperties: false,
properties: { version: { const: 1 } },
};
beforeEach(() => {
registerSettingSchema('Json_Config', schema);
settingsGetSettingMock.withArgs('Json_Config').returns({
_id: 'Json_Config',
type: 'code',
code: 'application/json',
});
});
it('accepts a JSON document matching the schema', () => {
expect(() => validateSettingRules([{ _id: 'Json_Config', value: '{"version":1}' }])).to.not.throw();
});
it('rejects a JSON document violating the schema', () => {
expect(() => validateSettingRules([{ _id: 'Json_Config', value: '{"version":2}' }])).to.throw('Json_Config_Invalid');
});
it('rejects a value that is not parseable JSON', () => {
expect(() => validateSettingRules([{ _id: 'Json_Config', value: 'not json {' }])).to.throw('Json_Config_Invalid');
});
it('accepts an empty value, meaning the setting is unconfigured', () => {
expect(() => validateSettingRules([{ _id: 'Json_Config', value: '' }])).to.not.throw();
});
it('ignores a schema registered for a code setting that is not application/json', () => {
registerSettingSchema('Js_Code', schema);
settingsGetSettingMock.withArgs('Js_Code').returns({
_id: 'Js_Code',
type: 'code',
code: 'text/javascript',
});
expect(() => validateSettingRules([{ _id: 'Js_Code', value: 'not json {' }])).to.not.throw();
});
});
});

View File

@@ -0,0 +1,69 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
describe('disableCustomScripts', () => {
let mockLicense: sinon.SinonStubbedInstance<any>;
let disableCustomScripts: () => boolean;
let disableCustomScriptsVar: any;
beforeEach(() => {
disableCustomScriptsVar = process.env.DISABLE_CUSTOM_SCRIPTS;
mockLicense = {
getLicense: sinon.stub(),
};
disableCustomScripts = proxyquire('../../../../../server/lib/shared/disableCustomScripts.ts', {
'@rocket.chat/license': { License: mockLicense },
}).disableCustomScripts;
});
afterEach(() => {
process.env.DISABLE_CUSTOM_SCRIPTS = disableCustomScriptsVar;
sinon.restore();
});
it('should return false when license is missing', () => {
mockLicense.getLicense.returns(null);
const result = disableCustomScripts();
expect(result).to.be.false;
});
it('should return false when DISABLE_CUSTOM_SCRIPTS is not true', () => {
mockLicense.getLicense.returns({
information: {
trial: true,
},
});
const result = disableCustomScripts();
expect(result).to.be.false;
});
it('should return false when license is not a trial', () => {
mockLicense.getLicense.returns({
information: {
trial: false,
},
});
process.env.DISABLE_CUSTOM_SCRIPTS = 'true';
const result = disableCustomScripts();
expect(result).to.be.false;
});
it('should return true when DISABLE_CUSTOM_SCRIPTS is true and license is a trial', () => {
mockLicense.getLicense.returns({
information: {
trial: true,
},
});
process.env.DISABLE_CUSTOM_SCRIPTS = 'true';
const result = disableCustomScripts();
expect(result).to.be.true;
});
});

View File

@@ -0,0 +1,74 @@
import { expect } from 'chai';
import { getModifiedHttpHeaders } from '../../../../../server/lib/shared/getModifiedHttpHeaders';
const inputVariants = [
{
label: 'HTTP path (Headers instance)',
makeInput: (headers: Record<string, string>) => new Headers(headers),
},
{
label: 'DDP path (plain object)',
makeInput: (headers: Record<string, string>) => headers,
},
];
describe('getModifiedHttpHeaders', () => {
for (const { label, makeInput } of inputVariants) {
describe(label, () => {
it('should redact x-auth-token if present', () => {
const inputHeaders = {
'x-auth-token': '12345',
'some-other-header': 'value',
};
const result = getModifiedHttpHeaders(makeInput(inputHeaders));
expect(result['x-auth-token']).to.equal('[redacted]');
expect(result['some-other-header']).to.equal('value');
});
it('should not modify headers if x-auth-token is not present', () => {
const inputHeaders = {
'some-other-header': 'value',
};
const result = getModifiedHttpHeaders(makeInput(inputHeaders));
expect(result).to.deep.equal(inputHeaders);
});
it('should redact rc_token in cookies if present', () => {
const inputHeaders = {
cookie: 'session_id=abc123; rc_token=98765; other_cookie=value',
};
const expectedCookies = 'session_id=abc123; rc_token=[redacted]; other_cookie=value';
const result = getModifiedHttpHeaders(makeInput(inputHeaders));
expect(result.cookie).to.equal(expectedCookies);
});
it('should not modify cookies if rc_token is not present', () => {
const inputHeaders = {
cookie: 'session_id=abc123; other_cookie=value',
};
const result = getModifiedHttpHeaders(makeInput(inputHeaders));
expect(result.cookie).to.equal(inputHeaders.cookie);
});
it('should return headers unchanged if neither x-auth-token nor cookie are present', () => {
const inputHeaders = {
'some-other-header': 'value',
};
const result = getModifiedHttpHeaders(makeInput(inputHeaders));
expect(result).to.deep.equal(inputHeaders);
});
it('should handle cases with both x-auth-token and rc_token in cookie', () => {
const inputHeaders = {
'x-auth-token': '12345',
'cookie': 'session_id=abc123; rc_token=98765; other_cookie=value',
};
const expectedCookies = 'session_id=abc123; rc_token=[redacted]; other_cookie=value';
const result = getModifiedHttpHeaders(makeInput(inputHeaders));
expect(result['x-auth-token']).to.equal('[redacted]');
expect(result.cookie).to.equal(expectedCookies);
});
});
}
});

View File

@@ -0,0 +1,56 @@
import { expect } from 'chai';
import { validateNameChars } from '../../../../../server/lib/shared/validateNameChars';
describe('validateNameChars', () => {
it('should return false for undefined input', () => {
expect(validateNameChars(undefined)).to.be.false;
});
it('should return false for non-string input', () => {
expect(validateNameChars(123 as any)).to.be.false;
expect(validateNameChars({} as any)).to.be.false;
expect(validateNameChars([] as any)).to.be.false;
});
it('should return false for names with invalid characters', () => {
expect(validateNameChars('name<')).to.be.false;
expect(validateNameChars('name>')).to.be.false;
expect(validateNameChars('name/')).to.be.false;
expect(validateNameChars('name\\')).to.be.false;
});
it('should return false for names with invalid characters after decoding', () => {
expect(validateNameChars('name%3E')).to.be.false;
expect(validateNameChars('name%5C')).to.be.false;
expect(validateNameChars('name%3C')).to.be.false;
});
it('should return false for malicious HTML payloads', () => {
expect(validateNameChars('<script>alert("XSS");</script>')).to.be.false;
expect(validateNameChars('%3Cscript%3Ealert%28%27XSS%27%29%3C%2Fscript%3E')).to.be.false;
expect(
validateNameChars(
'<form action="http://malicious.site" method="post"><input type="text" name="username" value="Enter username"><input type="password" name="password" value="Enter password"><input type="submit" value="Submit"></form>',
),
).to.be.false;
expect(
validateNameChars(
'%3Cform%20action%3D%22http%3A%2F%2Fmalicious.site%22%20method%3D%22post%22%3E%3Cinput%20type%3D%22text%22%20name%3D%22username%22%20value%3D%22Enter%20username%22%3E%3Cinput%20type%3D%22password%22%20name%3D%22password%22%20value%3D%22Enter%20password%22%3E%3Cinput%20type%3D%22submit%22%20value%3D%22Submit%22%3E%3C%2Fform%3E',
),
).to.be.false;
});
it('should return false if decodeURI throws an error', () => {
expect(validateNameChars('%')).to.be.false;
expect(validateNameChars('%E0%A4%A')).to.be.false;
});
it('should return true for valid names', () => {
expect(validateNameChars('name')).to.be.true;
expect(validateNameChars('valid_name')).to.be.true;
expect(validateNameChars('valid-name')).to.be.true;
expect(validateNameChars('valid.name')).to.be.true;
expect(validateNameChars('valid name')).to.be.true;
});
});

View File

@@ -0,0 +1,72 @@
import { expect } from 'chai';
import { UAParserMobile, UAParserDesktop } from '../../../../../../server/lib/statistics/lib/UAParserCustom';
const UAMobile = 'RC Mobile; iOS 12.2; v3.4.0 (250)';
const UADesktop =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Rocket.Chat/2.15.2 Chrome/69.0.3497.128 Electron/4.1.4 Safari/537.36';
const UAChrome =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36';
describe('UAParserCustom', () => {
describe('UAParserMobile', () => {
it('should identify mobile UA', () => {
expect(UAParserMobile.isMobileApp(UAMobile)).to.be.true;
});
it('should not identify desktop UA', () => {
expect(UAParserMobile.isMobileApp(UADesktop)).to.be.false;
});
it('should not identify chrome UA', () => {
expect(UAParserMobile.isMobileApp(UAChrome)).to.be.false;
});
it('should parse mobile UA', () => {
expect(UAParserMobile.uaObject(UAMobile)).to.be.deep.equal({
device: {
type: 'mobile-app',
},
app: {
name: 'RC Mobile',
version: '3.4.0',
bundle: '250',
},
os: {
name: 'iOS',
version: '12.2',
},
});
});
});
describe('UAParserDesktop', () => {
it('should not identify mobile UA', () => {
expect(UAParserDesktop.isDesktopApp(UAMobile)).to.be.false;
});
it('should identify desktop UA', () => {
expect(UAParserDesktop.isDesktopApp(UADesktop)).to.be.true;
});
it('should not identify chrome UA', () => {
expect(UAParserDesktop.isDesktopApp(UAChrome)).to.be.false;
});
it('should parse desktop UA', () => {
expect(UAParserDesktop.uaObject(UADesktop)).to.be.deep.equal({
device: {
type: 'desktop-app',
},
app: {
name: 'Rocket.Chat',
version: '2.15.2',
},
os: {
name: 'Mac OS',
version: '10.14.1',
},
});
});
});
});

View File

@@ -0,0 +1,48 @@
import type { ISubscription } from '@rocket.chat/core-typings';
import { isBannedSubscription, isInviteSubscription } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { describe, it } from 'mocha';
describe('ISubscription type guards', () => {
describe('isBannedSubscription', () => {
it('should return true for a subscription with BANNED status', () => {
const sub = { status: 'BANNED' } as ISubscription;
expect(isBannedSubscription(sub)).to.be.true;
});
it('should return false for a subscription with INVITED status', () => {
const sub = { status: 'INVITED' } as ISubscription;
expect(isBannedSubscription(sub)).to.be.false;
});
it('should return false for a subscription with no status', () => {
const sub = {} as ISubscription;
expect(isBannedSubscription(sub)).to.be.false;
});
it('should return false for a subscription with undefined status', () => {
const sub = { status: undefined } as unknown as ISubscription;
expect(isBannedSubscription(sub)).to.be.false;
});
});
describe('isInviteSubscription', () => {
it('should return true for a subscription with INVITED status and inviter', () => {
const sub = {
status: 'INVITED',
inviter: { _id: 'user1', username: 'testuser' },
} as ISubscription;
expect(isInviteSubscription(sub)).to.be.true;
});
it('should return false for a subscription with INVITED status but no inviter', () => {
const sub = { status: 'INVITED' } as ISubscription;
expect(isInviteSubscription(sub)).to.be.false;
});
it('should return false for a BANNED subscription', () => {
const sub = { status: 'BANNED' } as ISubscription;
expect(isInviteSubscription(sub)).to.be.false;
});
});
});

View File

@@ -0,0 +1,53 @@
import type { MessageTypesValues } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { isMutedUnmuted, shouldHideSystemMessage } from '../../../../../server/lib/systemMessage/hideSystemMessage';
describe('hideSystemMessage', () => {
describe('isMutedUnmuted', () => {
it('should return true for user-muted', () => {
expect(isMutedUnmuted('user-muted')).to.be.true;
});
it('should return true for user-unmuted', () => {
expect(isMutedUnmuted('user-unmuted')).to.be.true;
});
it('should return false for other message types', () => {
expect(isMutedUnmuted('some-other-type')).to.be.false;
});
});
describe('shouldHideSystemMessage', () => {
it('should return true if message type is in hidden system messages', async () => {
const hiddenMessages: MessageTypesValues[] = ['user-muted', 'mute_unmute'];
const result = shouldHideSystemMessage('user-muted', hiddenMessages);
expect(result).to.be.true;
});
it('should return true if message type is user-muted and mute_unmute is in hidden system messages', async () => {
const hiddenMessages: MessageTypesValues[] = ['mute_unmute'];
const result = shouldHideSystemMessage('user-muted', hiddenMessages);
expect(result).to.be.true;
});
it('should return false if message type is not in hidden system messages', async () => {
const hiddenMessages: MessageTypesValues[] = ['room-archived'];
const result = shouldHideSystemMessage('user-muted', hiddenMessages);
expect(result).to.be.false;
});
it('should return false if message type is of deleted message', async () => {
const result = shouldHideSystemMessage('rm', undefined);
expect(result).to.be.false;
});
it('should return false if hidden system messages are undefined', async () => {
const result = shouldHideSystemMessage('user-muted', undefined);
expect(result).to.be.false;
});
});
});

View File

@@ -0,0 +1,154 @@
import { MeteorError } from '@rocket.chat/core-services';
import { expect } from 'chai';
import { describe, it, beforeEach, afterEach } from 'mocha';
import mock from 'proxyquire';
import Sinon from 'sinon';
type SendUserEmailFn = (subject: string, html: string, user: { email?: string; password?: string; name?: string }) => Promise<void>;
type SendHelperFn = (user: { email?: string; password?: string; name?: string }) => Promise<void>;
const userData = {
email: 'test@example.com',
password: '123456',
name: 'Test User',
};
const makeStubs = () => {
const MailerStub = {
getTemplate: Sinon.stub(),
send: Sinon.stub(),
};
const SettingsStub = {
settings: {
get: Sinon.stub().callsFake((key: string) => {
if (key === 'From_Email') return 'no-reply@example.com';
if (key === 'Accounts_UserAddedEmail_Subject') return 'Welcome [name]!';
if (key === 'Password_Changed_Email_Subject') return 'Password Changed';
return '';
}),
},
};
const MeteorStub = {
Meteor: {
startup: Sinon.stub(),
},
};
return { MailerStub, SettingsStub, MeteorStub };
};
describe('sendUserEmail (Mocha + TS)', () => {
let MailerStub: { getTemplate: Sinon.SinonStub; send: Sinon.SinonStub };
let SettingsStub: { settings: { get: Sinon.SinonStub } };
let MeteorStub: { Meteor: { startup: Sinon.SinonStub } };
let sendUserEmail: SendUserEmailFn;
let sendWelcomeEmail: SendHelperFn;
let sendPasswordEmail: SendHelperFn;
beforeEach(() => {
const stubs = makeStubs();
MailerStub = stubs.MailerStub as any;
SettingsStub = stubs.SettingsStub as any;
MeteorStub = stubs.MeteorStub as any;
const mod = mock.noCallThru().load('../../../../../../server/lib/users/saveUser/sendUserEmail.ts', {
'../../notifications/email/api': MailerStub,
'../../../settings': SettingsStub,
'meteor/meteor': MeteorStub,
}) as any;
sendUserEmail = mod.sendUserEmail as SendUserEmailFn;
sendWelcomeEmail = mod.sendWelcomeEmail as SendHelperFn;
sendPasswordEmail = mod.sendPasswordEmail as SendHelperFn;
});
afterEach(() => {
Sinon.restore();
});
describe('sendUserEmail', () => {
it('should not send email if userData.email is missing', async () => {
await sendUserEmail('subject', '<p>html</p>', { ...userData, email: undefined });
Sinon.assert.notCalled(MailerStub.send);
});
it('should send email with correct parameters', async () => {
await sendUserEmail('subject', '<p>html</p>', userData);
Sinon.assert.calledOnce(MailerStub.send);
const callArg = MailerStub.send.getCall(0).args[0];
expect(callArg).to.be.an('object');
expect(callArg).to.include({
to: userData.email,
from: SettingsStub.settings.get('From_Email'),
subject: 'subject',
html: '<p>html</p>',
});
expect(callArg.data).to.include({
email: userData.email,
password: userData.password,
name: userData.name,
});
});
it('should throw MeteorError if Mailer.send fails', async () => {
MailerStub.send.throws(new Error('fail'));
let thrown = false;
try {
await sendUserEmail('subject', '<p>html</p>', userData);
} catch (err) {
thrown = true;
expect(err).to.be.instanceOf(MeteorError);
}
expect(thrown).to.equal(true);
});
it('should mask password in email data if password is not provided', async () => {
await sendUserEmail('subject', '<p>html</p>', { ...userData, password: undefined });
Sinon.assert.calledOnce(MailerStub.send);
const callArg = MailerStub.send.getCall(0).args[0];
expect(callArg.data).to.include({
email: userData.email,
password: '******',
name: userData.name,
});
});
});
describe('sendWelcomeEmail', () => {
it('should call sendUserEmail with welcome subject and template', async () => {
await sendWelcomeEmail(userData);
Sinon.assert.calledOnce(MailerStub.send);
const callArg = MailerStub.send.getCall(0).args[0];
expect(callArg).to.include({
subject: `Welcome [name]!`,
to: userData.email,
});
expect(callArg.data).to.deep.equal({
email: userData.email,
password: userData.password,
name: userData.name,
});
});
});
describe('sendPasswordEmail', () => {
it('should call sendUserEmail with password subject and template', async () => {
await sendPasswordEmail(userData);
Sinon.assert.calledOnce(MailerStub.send);
const callArg = MailerStub.send.getCall(0).args[0];
expect(callArg).to.include({
subject: 'Password Changed',
to: userData.email,
});
});
});
});

View File

@@ -0,0 +1,336 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
describe('setUsername', () => {
const userId = 'userId';
const username = 'validUsername';
const stubs = {
Users: {
findOneById: sinon.stub(),
setUsername: sinon.stub(),
},
Subscriptions: {
findUserFederatedRoomIds: sinon.stub(),
},
Accounts: {
sendEnrollmentEmail: sinon.stub(),
},
settings: {
get: sinon.stub(),
},
api: {
broadcast: sinon.stub(),
},
Invites: {
findOneById: sinon.stub(),
},
callbacks: {
run: sinon.stub(),
runAsync: sinon.stub(),
},
checkUsernameAvailability: sinon.stub(),
validateUsername: sinon.stub(),
saveUserIdentity: sinon.stub(),
joinDefaultChannels: sinon.stub(),
getAvatarSuggestionForUser: sinon.stub(),
setUserAvatar: sinon.stub(),
addUserToRoom: sinon.stub(),
notifyOnUserChange: sinon.stub(),
underscore: {
escape: sinon.stub(),
},
SystemLogger: sinon.stub(),
};
const { setUsernameWithValidation, _setUsername } = proxyquire.noCallThru().load('../../../../../server/lib/users/setUsername', {
'../../database/utils': { onceTransactionCommitedSuccessfully: async (cb: any, _sess: any) => cb() },
'meteor/meteor': { Meteor: { Error } },
'@rocket.chat/core-services': { api: stubs.api },
'@rocket.chat/models': { Users: stubs.Users, Invites: stubs.Invites, Subscriptions: stubs.Subscriptions },
'meteor/accounts-base': { Accounts: stubs.Accounts },
'underscore': stubs.underscore,
'../../settings': { settings: stubs.settings },
'../notifyListener': { notifyOnUserChange: stubs.notifyOnUserChange },
'../rooms/addUserToRoom': { addUserToRoom: stubs.addUserToRoom },
'./checkUsernameAvailability': { checkUsernameAvailability: stubs.checkUsernameAvailability },
'./getAvatarSuggestionForUser': { getAvatarSuggestionForUser: stubs.getAvatarSuggestionForUser },
'../rooms/joinDefaultChannels': { joinDefaultChannels: stubs.joinDefaultChannels },
'./saveUserIdentity': { saveUserIdentity: stubs.saveUserIdentity },
'./setUserAvatar': { setUserAvatar: stubs.setUserAvatar },
'./validateUsername': { validateUsername: stubs.validateUsername },
'../callbacks': { callbacks: stubs.callbacks },
'../logger/system': { SystemLogger: stubs.SystemLogger },
});
beforeEach(() => {
stubs.Subscriptions.findUserFederatedRoomIds.returns({
hasNext: sinon.stub().resolves(false),
close: sinon.stub().resolves(),
});
});
afterEach(() => {
stubs.Users.findOneById.reset();
stubs.Users.setUsername.reset();
stubs.Subscriptions.findUserFederatedRoomIds.reset();
stubs.Accounts.sendEnrollmentEmail.reset();
stubs.settings.get.reset();
stubs.api.broadcast.reset();
stubs.Invites.findOneById.reset();
stubs.callbacks.run.reset();
stubs.callbacks.runAsync.reset();
stubs.checkUsernameAvailability.reset();
stubs.validateUsername.reset();
stubs.saveUserIdentity.reset();
stubs.joinDefaultChannels.reset();
stubs.getAvatarSuggestionForUser.reset();
stubs.setUserAvatar.reset();
stubs.addUserToRoom.reset();
stubs.notifyOnUserChange.reset();
stubs.underscore.escape.reset();
stubs.SystemLogger.reset();
});
describe('setUsernameWithValidation', () => {
it('should throw an error if username is invalid', async () => {
try {
await setUsernameWithValidation(userId, '');
} catch (error: any) {
expect(error.message).to.equal('error-invalid-username');
}
});
it('should throw an error if user is not found', async () => {
stubs.Users.findOneById.withArgs(userId).returns(null);
try {
await setUsernameWithValidation(userId, username);
} catch (error: any) {
expect(stubs.Users.findOneById.calledOnce).to.be.true;
expect(error.message).to.equal('error-invalid-user');
}
});
it('should throw an error if username change is not allowed', async () => {
stubs.Users.findOneById.resolves({ username: 'oldUsername' });
stubs.settings.get.withArgs('Accounts_AllowUsernameChange').returns(false);
try {
await setUsernameWithValidation(userId, username);
} catch (error: any) {
expect(stubs.settings.get.calledOnce).to.be.true;
expect(error.message).to.equal('error-not-allowed');
}
});
it('should throw an error if username is not valid', async () => {
stubs.Users.findOneById.resolves({ username: null });
stubs.validateUsername.returns(false);
try {
await setUsernameWithValidation(userId, 'invalid-username');
} catch (error: any) {
expect(stubs.validateUsername.calledOnce).to.be.true;
expect(error.message).to.equal('username-invalid');
}
});
it('should throw an error if username is already in use', async () => {
stubs.Users.findOneById.resolves({ username: null });
stubs.validateUsername.returns(true);
stubs.checkUsernameAvailability.resolves(false);
try {
await setUsernameWithValidation(userId, 'existingUsername');
} catch (error: any) {
expect(stubs.checkUsernameAvailability.calledOnce).to.be.true;
expect(error.message).to.equal('error-field-unavailable');
}
});
it('should throw an error if local user is in federated rooms', async () => {
stubs.Users.findOneById.resolves({ _id: userId, username: null });
stubs.validateUsername.returns(true);
stubs.checkUsernameAvailability.resolves(true);
stubs.Subscriptions.findUserFederatedRoomIds.returns({
hasNext: sinon.stub().resolves(true),
close: sinon.stub().resolves(),
});
try {
await setUsernameWithValidation(userId, 'newUsername');
} catch (error: any) {
expect(stubs.Subscriptions.findUserFederatedRoomIds.calledOnce).to.be.true;
expect(error.message).to.equal('error-not-allowed');
}
});
it('should throw an error if user is federated', async () => {
stubs.Users.findOneById.resolves({
_id: userId,
username: null,
federated: true,
federation: { version: 1, mui: '@user:origin', origin: 'origin' },
});
stubs.validateUsername.returns(true);
stubs.checkUsernameAvailability.resolves(true);
try {
await setUsernameWithValidation(userId, 'newUsername');
} catch (error: any) {
expect(stubs.Subscriptions.findUserFederatedRoomIds.notCalled).to.be.true;
expect(error.message).to.equal('error-not-allowed');
}
});
it('should save the user identity when valid username is set', async () => {
stubs.Users.findOneById.resolves({ _id: userId, username: null });
stubs.settings.get.withArgs('Accounts_AllowUsernameChange').returns(true);
stubs.validateUsername.returns(true);
stubs.checkUsernameAvailability.resolves(true);
stubs.saveUserIdentity.resolves(true);
await setUsernameWithValidation(userId, 'newUsername');
expect(stubs.saveUserIdentity.calledOnce).to.be.true;
});
});
describe('_setUsername', () => {
it('should return false if userId or username is missing', async () => {
const result = await _setUsername(null, '', {});
expect(result).to.be.false;
});
it('should return false if username is invalid', async () => {
stubs.validateUsername.returns(false);
const result = await _setUsername(userId, 'invalid-username', {});
expect(result).to.be.false;
});
it('should return user if username is already set', async () => {
stubs.validateUsername.returns(true);
const mockUser = { username };
const result = await _setUsername(userId, username, mockUser);
expect(result).to.equal(mockUser);
});
it('should set username when user has no previous username', async () => {
const mockUser = { _id: userId, emails: [{ address: 'test@example.com' }] };
stubs.validateUsername.returns(true);
stubs.Users.findOneById.resolves(mockUser);
stubs.checkUsernameAvailability.resolves(true);
await _setUsername(userId, username, mockUser);
expect(stubs.Users.setUsername.calledOnceWith(userId, username));
expect(stubs.checkUsernameAvailability.calledOnceWith(username));
expect(stubs.api.broadcast.calledOnceWith('user.autoupdate', { user: mockUser }));
});
it('should set username when user has and old that is different from new', async () => {
const mockUser = { _id: userId, username: 'oldUsername', emails: [{ address: 'test@example.com' }] };
stubs.validateUsername.returns(true);
stubs.Users.findOneById.resolves(mockUser);
stubs.checkUsernameAvailability.resolves(true);
await _setUsername(userId, username, mockUser);
expect(stubs.Users.setUsername.calledOnceWith(userId, username));
expect(stubs.checkUsernameAvailability.calledOnceWith(username));
expect(stubs.api.broadcast.calledOnceWith('user.autoupdate', { user: mockUser }));
});
it('should set username when user has and old that is different from new', async () => {
const mockUser = { _id: userId, username: 'oldUsername', emails: [{ address: 'test@example.com' }] };
stubs.validateUsername.returns(true);
stubs.Users.findOneById.resolves(mockUser);
stubs.checkUsernameAvailability.resolves(true);
await _setUsername(userId, username, mockUser);
expect(stubs.Users.setUsername.calledOnceWith(userId, username));
expect(stubs.checkUsernameAvailability.calledOnceWith(username));
expect(stubs.api.broadcast.calledOnceWith('user.autoupdate', { user: mockUser }));
});
it('should join the default channels and run afterCreateUser on the first username', async () => {
const mockUser = { _id: userId, roles: ['user'] };
stubs.validateUsername.returns(true);
stubs.checkUsernameAvailability.resolves(true);
await _setUsername(userId, username, mockUser);
expect(stubs.joinDefaultChannels.calledOnceWith(userId)).to.be.true;
expect(stubs.callbacks.runAsync.calledWith('afterCreateUser')).to.be.true;
expect(stubs.callbacks.runAsync.withArgs('afterCreateUser').firstCall.args[1]).to.include({ username });
});
it('should not join the default channels when the username is only being changed', async () => {
const mockUser = { _id: userId, username: 'oldUsername', roles: ['user'] };
stubs.validateUsername.returns(true);
stubs.checkUsernameAvailability.resolves(true);
await _setUsername(userId, username, mockUser);
expect(stubs.joinDefaultChannels.notCalled).to.be.true;
expect(stubs.callbacks.runAsync.calledWith('afterCreateUser')).to.be.false;
});
it('should set avatar if Accounts_SetDefaultAvatar is enabled', async () => {
const mockUser = { _id: userId, username: null };
stubs.validateUsername.returns(true);
stubs.Users.findOneById.resolves(mockUser);
stubs.checkUsernameAvailability.resolves(true);
stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(true);
stubs.getAvatarSuggestionForUser.resolves({ gravatar: { blob: 'blobData', contentType: 'image/png' } });
await _setUsername(userId, username, mockUser);
expect(stubs.setUserAvatar.calledOnceWith(mockUser, 'blobData', 'image/png', 'gravatar')).to.be.true;
});
it('should not set avatar if Accounts_SetDefaultAvatar is disabled', async () => {
const mockUser = { _id: userId, username: null };
stubs.validateUsername.returns(true);
stubs.Users.findOneById.resolves(mockUser);
stubs.checkUsernameAvailability.resolves(true);
stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(false);
await _setUsername(userId, username, mockUser);
expect(stubs.setUserAvatar.called).to.be.false;
});
it('should not set avatar if no avatar suggestions are available', async () => {
const mockUser = { _id: userId, username: null };
stubs.validateUsername.returns(true);
stubs.Users.findOneById.resolves(mockUser);
stubs.checkUsernameAvailability.resolves(true);
stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(true);
stubs.getAvatarSuggestionForUser.resolves({});
await _setUsername(userId, username, mockUser);
expect(stubs.setUserAvatar.called).to.be.false;
});
it('should add user to room if inviteToken is present', async () => {
const mockUser = { _id: userId, username: null, inviteToken: 'invite token' };
stubs.validateUsername.returns(true);
stubs.Users.findOneById.resolves(mockUser);
stubs.checkUsernameAvailability.resolves(true);
stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(true);
stubs.getAvatarSuggestionForUser.resolves({ gravatar: { blob: 'blobData', contentType: 'image/png' } });
stubs.Invites.findOneById.resolves({ rid: 'room id' });
await _setUsername(userId, username, mockUser);
expect(stubs.addUserToRoom.calledOnceWith('room id', mockUser)).to.be.true;
});
});
});

View File

@@ -0,0 +1,94 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
describe('validateUsername', () => {
const getStub = sinon.stub();
const proxySettings = {
settings: {
get: getStub,
},
};
const { validateUsername } = proxyquire.noCallThru().load('../../../../../server/lib/users/validateUsername', {
'../../settings': proxySettings,
});
beforeEach(() => {
getStub.reset();
});
afterEach(() => {
sinon.restore();
});
describe('with default settings', () => {
beforeEach(() => {
getStub.withArgs('UTF8_User_Names_Validation').returns('[0-9a-zA-Z-_.]+');
});
it('should return true for a valid username', () => {
const result = validateUsername('valid_username.123');
expect(result).to.be.true;
});
it('should return false for an invalid username containing special HTML tags', () => {
const result = validateUsername('username<div>$</div>');
expect(result).to.be.false;
});
it('should return false for an empty username', () => {
const result = validateUsername('');
expect(result).to.be.false;
});
it('should return false for a username with invalid characters', () => {
const result = validateUsername('invalid*username!');
expect(result).to.be.false;
});
it('should return true for a username with allowed special characters', () => {
const result = validateUsername('username-_.');
expect(result).to.be.true;
});
});
describe('with custom regex settings', () => {
beforeEach(() => {
getStub.withArgs('UTF8_User_Names_Validation').returns('[a-zA-Z]+');
});
it('should return true for a username matching the custom regex', () => {
const result = validateUsername('ValidUsername');
expect(result).to.be.true;
});
it('should return false for a username that does not match the custom regex', () => {
const result = validateUsername('username123');
expect(result).to.be.false;
});
});
describe('with null regex settings', () => {
beforeEach(() => {
getStub.withArgs('UTF8_User_Names_Validation').returns(null);
});
it('should fallback to the default regex pattern if the settings value is null', () => {
const result = validateUsername('username');
expect(result).to.be.true;
});
});
describe('with invalid regex settings', () => {
beforeEach(() => {
getStub.withArgs('UTF8_User_Names_Validation').returns('invalid[');
});
it('should fallback to the default regex pattern if the settings value is invalid', () => {
const result = validateUsername('username');
expect(result).to.be.true;
});
});
});