chore: `applyDepartmentRestrictions` to new package structure (#36733)

pull/36782/head
Kevin Aleman 5 months ago committed by GitHub
parent 776af0ae53
commit 1457924eb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      apps/meteor/app/authorization/server/functions/hasRole.ts
  2. 18
      apps/meteor/app/livechat/server/api/lib/departments.ts
  3. 3
      apps/meteor/app/livechat/server/lib/closeRoom.ts
  4. 3
      apps/meteor/ee/app/livechat-enterprise/server/api/lib/units.ts
  5. 32
      apps/meteor/ee/app/livechat-enterprise/server/hooks/applyDepartmentRestrictions.ts
  6. 1
      apps/meteor/ee/app/livechat-enterprise/server/hooks/index.ts
  7. 2
      apps/meteor/ee/app/livechat-enterprise/server/hooks/manageDepartmentUnit.ts
  8. 5
      apps/meteor/ee/app/livechat-enterprise/server/lib/Department.ts
  9. 2
      apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts
  10. 2
      apps/meteor/ee/app/livechat-enterprise/server/lib/restrictQuery.ts
  11. 2
      apps/meteor/ee/app/livechat-enterprise/server/lib/unit.ts
  12. 48
      apps/meteor/ee/app/livechat-enterprise/server/methods/getUnitsFromUserRoles.ts
  13. 2
      apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/hooks/manageDepartmentUnit.spec.ts
  14. 4
      apps/meteor/lib/callbacks.ts
  15. 12
      apps/meteor/server/services/authorization/service.ts
  16. 9
      ee/packages/omni-core-ee/package.json
  17. 4
      ee/packages/omni-core-ee/src/index.ts
  18. 24
      ee/packages/omni-core-ee/src/patches/applyDepartmentRestrictions.ts
  19. 297
      ee/packages/omni-core-ee/src/units/addRoleBasedRestrictionsToDepartment.spec.ts
  20. 17
      ee/packages/omni-core-ee/src/units/addRoleBasedRestrictionsToDepartment.ts
  21. 230
      ee/packages/omni-core-ee/src/units/getUnitsFromUser.spec.ts
  22. 48
      ee/packages/omni-core-ee/src/units/getUnitsFromUser.ts
  23. 4
      ee/packages/omni-core-ee/src/utils/logger.ts
  24. 3
      packages/core-services/src/types/IAuthorization.ts
  25. 6
      packages/omni-core/package.json
  26. 7
      packages/omni-core/src/hooks/applyDepartmentRestrictions.ts
  27. 1
      packages/omni-core/src/index.ts

@ -1,6 +1,9 @@
import type { IRole, IUser, IRoom, ISubscription } from '@rocket.chat/core-typings';
import { Roles } from '@rocket.chat/models';
/**
* @deprecated use `Authorization.hasAnyRole` instead
*/
export const hasAnyRoleAsync = async (
userId: IUser['_id'],
roleIds: IRole['_id'][],

@ -1,10 +1,10 @@
import type { ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models';
import { applyDepartmentRestrictions } from '@rocket.chat/omni-core';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import type { Document, Filter, FindOptions } from 'mongodb';
import type { Document, Filter, FilterOperators, FindOptions } from 'mongodb';
import { callbacks } from '../../../../../lib/callbacks';
import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
type Pagination<T extends Document> = { pagination: { offset: number; count: number; sort: FindOptions<T>['sort'] } };
@ -46,7 +46,7 @@ export async function findDepartments({
showArchived = false,
pagination: { offset, count, sort },
}: FindDepartmentParams): Promise<PaginatedResult<{ departments: ILivechatDepartment[] }>> {
let query = {
let query: FilterOperators<ILivechatDepartment> = {
$or: [{ type: { $eq: 'd' } }, { type: { $exists: false } }],
...(!showArchived && { archived: { $ne: !showArchived } }),
...(enabled && { enabled: Boolean(enabled) }),
@ -55,7 +55,7 @@ export async function findDepartments({
};
if (onlyMyDepartments) {
query = await callbacks.run('livechat.applyDepartmentRestrictions', query, { userId });
query = await applyDepartmentRestrictions(query, userId);
}
const { cursor, totalCount } = LivechatDepartment.findPaginated(query, {
@ -81,7 +81,7 @@ export async function findArchivedDepartments({
excludeDepartmentId,
pagination: { offset, count, sort },
}: FindDepartmentParams): Promise<PaginatedResult<{ departments: ILivechatDepartment[] }>> {
let query = {
let query: FilterOperators<ILivechatDepartment> = {
$or: [{ type: { $eq: 'd' } }, { type: { $exists: false } }],
archived: { $eq: true },
...(text && { name: new RegExp(escapeRegExp(text), 'i') }),
@ -89,7 +89,7 @@ export async function findArchivedDepartments({
};
if (onlyMyDepartments) {
query = await callbacks.run('livechat.applyDepartmentRestrictions', query, { userId });
query = await applyDepartmentRestrictions(query, userId);
}
const { cursor, totalCount } = LivechatDepartment.findPaginated(query, {
@ -119,10 +119,10 @@ export async function findDepartmentById({
}> {
const canViewLivechatDepartments = includeAgents && (await hasPermissionAsync(userId, 'view-livechat-departments'));
let query = { _id: departmentId };
let query: FilterOperators<ILivechatDepartment> = { _id: departmentId };
if (onlyMyDepartments) {
query = await callbacks.run('livechat.applyDepartmentRestrictions', query, { userId });
query = await applyDepartmentRestrictions(query, userId);
}
const result = {
@ -146,7 +146,7 @@ export async function findDepartmentsToAutocomplete({
let { conditions = {} } = selector;
if (onlyMyDepartments) {
conditions = await callbacks.run('livechat.applyDepartmentRestrictions', conditions, { userId: uid });
conditions = await applyDepartmentRestrictions(conditions, uid);
}
const conditionsWithArchived = { archived: { $ne: !showArchived }, ...conditions };

@ -3,6 +3,7 @@ import { Message } from '@rocket.chat/core-services';
import type { ILivechatDepartment, ILivechatInquiryRecord, IOmnichannelRoom, IOmnichannelRoomClosingInfo } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatInquiry, LivechatRooms, Subscriptions, Users } from '@rocket.chat/models';
import { applyDepartmentRestrictions } from '@rocket.chat/omni-core';
import type { ClientSession } from 'mongodb';
import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes';
@ -283,7 +284,7 @@ export async function closeOpenChats(userId: string, comment?: string) {
logger.debug(`Closing open chats for user ${userId}`);
const user = await Users.findOneById(userId);
const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}, { userId });
const extraQuery = await applyDepartmentRestrictions({}, userId);
const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery);
const promises: Promise<void>[] = [];
await openChats.forEach((room) => {

@ -1,10 +1,9 @@
import type { IOmnichannelBusinessUnit, ILivechatUnitMonitor } from '@rocket.chat/core-typings';
import { LivechatUnitMonitors, LivechatUnit } from '@rocket.chat/models';
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import type { FindOptions } from 'mongodb';
import { getUnitsFromUser } from '../../methods/getUnitsFromUserRoles';
export async function findUnitsOfUser({
text,
userId,

@ -1,32 +0,0 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { FilterOperators } from 'mongodb';
import { callbacks } from '../../../../../lib/callbacks';
import { cbLogger } from '../lib/logger';
import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles';
export const addQueryRestrictionsToDepartmentsModel = async (originalQuery: FilterOperators<ILivechatDepartment> = {}, userId: string) => {
const query: FilterOperators<ILivechatDepartment> = { $and: [originalQuery, { type: { $ne: 'u' } }] };
const units = await getUnitsFromUser(userId);
if (Array.isArray(units)) {
query.$and.push({ $or: [{ ancestors: { $in: units } }, { _id: { $in: units } }] });
}
cbLogger.debug({ msg: 'Applying department query restrictions', userId, units });
return query;
};
callbacks.add(
'livechat.applyDepartmentRestrictions',
async (originalQuery: FilterOperators<ILivechatDepartment> = {}, { userId }: { userId?: string | null } = { userId: null }) => {
if (!userId) {
return originalQuery;
}
cbLogger.debug('Applying department query restrictions');
return addQueryRestrictionsToDepartmentsModel(originalQuery, userId);
},
callbacks.priority.HIGH,
'livechat-apply-department-restrictions',
);

@ -17,7 +17,6 @@ import './onBusinessHourStart';
import './onAgentAssignmentFailed';
import './afterOnHoldChatResumed';
import './afterReturnRoomAsInquiry';
import './applyDepartmentRestrictions';
import './afterForwardChatToAgent';
import './applySimultaneousChatsRestrictions';
import './afterInquiryQueued';

@ -1,9 +1,9 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatUnit } from '@rocket.chat/models';
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
import { hasAnyRoleAsync } from '../../../../../app/authorization/server/functions/hasRole';
import { callbacks } from '../../../../../lib/callbacks';
import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles';
export const manageDepartmentUnit = async ({ userId, departmentId, unitId }: { userId: string; departmentId: string; unitId: string }) => {
const accessibleUnits = await getUnitsFromUser(userId);

@ -1,10 +1,9 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import { applyDepartmentRestrictions } from '@rocket.chat/omni-core';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import type { Filter } from 'mongodb';
import { callbacks } from '../../../../../lib/callbacks';
export const findAllDepartmentsAvailable = async (
uid: string,
unitId: string,
@ -22,7 +21,7 @@ export const findAllDepartmentsAvailable = async (
};
if (onlyMyDepartments) {
query = await callbacks.run('livechat.applyDepartmentRestrictions', query, { userId: uid });
query = await applyDepartmentRestrictions(query, uid);
}
const { cursor, totalCount } = LivechatDepartment.findPaginated(query, { limit: count, offset, sort: { name: 1 } });

@ -1,5 +1,6 @@
import type { IOmnichannelBusinessUnit, IOmnichannelServiceLevelAgreements, IUser, ILivechatTag } from '@rocket.chat/core-typings';
import { Users, OmnichannelServiceLevelAgreements, LivechatTag, LivechatUnitMonitors, LivechatUnit } from '@rocket.chat/models';
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
@ -8,7 +9,6 @@ import { removeSLAFromRooms } from './SlaHelper';
import { callbacks } from '../../../../../lib/callbacks';
import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles';
import { removeUserFromRolesAsync } from '../../../../../server/lib/roles/removeUserFromRoles';
import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles';
export const LivechatEnterprise = {
async addMonitor(username: string) {

@ -1,9 +1,9 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
import type { FilterOperators } from 'mongodb';
import { cbLogger } from './logger';
import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles';
export const restrictQuery = async ({
originalQuery = {},

@ -1,8 +1,8 @@
import { LivechatUnit } from '@rocket.chat/models';
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
import type { CheckUnitsFromUser } from '../../../../../app/livechat/server/api/lib/livechat';
import { checkUnitsFromUser } from '../../../../../app/livechat/server/api/lib/livechat';
import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles';
checkUnitsFromUser.patch(async (_next, { businessUnit, userId }: CheckUnitsFromUser) => {
if (!businessUnit) {

@ -1,54 +1,8 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { LivechatUnit, LivechatDepartmentAgents } from '@rocket.chat/models';
import mem from 'mem';
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
import { Meteor } from 'meteor/meteor';
import { hasAnyRoleAsync } from '../../../../../app/authorization/server/functions/hasRole';
import { methodDeprecationLogger } from '../../../../../app/lib/server/lib/deprecationWarningLogger';
import { logger } from '../lib/logger';
async function getUnitsFromUserRoles(user: string): Promise<string[]> {
return LivechatUnit.findByMonitorId(user);
}
async function getDepartmentsFromUserRoles(user: string): Promise<string[]> {
return (await LivechatDepartmentAgents.findByAgentId(user).toArray()).map((department) => department.departmentId);
}
const memoizedGetUnitFromUserRoles = mem(getUnitsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 });
const memoizedGetDepartmentsFromUserRoles = mem(getDepartmentsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 });
async function hasUnits(): Promise<boolean> {
// @ts-expect-error - this prop is injected dynamically on ee license
return (await LivechatUnit.countUnits({ type: 'u' })) > 0;
}
// Units should't change really often, so we can cache the result
const memoizedHasUnits = mem(hasUnits, { maxAge: process.env.TEST_MODE ? 1 : 10000 });
export const getUnitsFromUser = async (userId?: string): Promise<string[] | undefined> => {
if (!userId) {
return;
}
if (!(await memoizedHasUnits())) {
return;
}
// TODO: we can combine these 2 calls into one single query
if (await hasAnyRoleAsync(userId, ['admin', 'livechat-manager'])) {
return;
}
if (!(await hasAnyRoleAsync(userId, ['livechat-monitor', 'livechat-agent']))) {
return;
}
const unitsAndDepartments = [...(await memoizedGetUnitFromUserRoles(userId)), ...(await memoizedGetDepartmentsFromUserRoles(userId))];
logger.debug({ msg: 'Calculating units for monitor', user: userId, unitsAndDepartments });
return unitsAndDepartments;
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention

@ -21,7 +21,7 @@ const getUnitsFromUserStub = sinon.stub();
const { manageDepartmentUnit } = proxyquire
.noCallThru()
.load('../../../../../../app/livechat-enterprise/server/hooks/manageDepartmentUnit.ts', {
'../methods/getUnitsFromUserRoles': {
'@rocket.chat/omni-core-ee': {
getUnitsFromUser: getUnitsFromUserStub,
},
'../../../../../app/authorization/server/functions/hasRole': {

@ -151,10 +151,6 @@ type ChainedCallbackSignatures = {
agentsId: ILivechatAgent['_id'][];
};
'livechat.applySimultaneousChatRestrictions': (_: undefined, params: { departmentId?: ILivechatDepartmentRecord['_id'] }) => undefined;
'livechat.applyDepartmentRestrictions': (
query: FilterOperators<ILivechatDepartmentRecord>,
params: { userId: IUser['_id'] },
) => FilterOperators<ILivechatDepartmentRecord>;
'livechat.applyRoomRestrictions': (
query: FilterOperators<IOmnichannelRoom>,
params?: {

@ -189,4 +189,16 @@ export class Authorization extends ServiceClass implements IAuthorization {
return true;
}
async hasAnyRole(userId: IUser['_id'], roleIds: IRole['_id'][], scope?: IRoom['_id']): Promise<boolean> {
if (!Array.isArray(roleIds)) {
throw new Error('error-invalid-arguments');
}
if (!userId) {
return false;
}
return Roles.isUserInRoles(userId, roleIds, scope);
}
}

@ -25,7 +25,14 @@
"/dist"
],
"dependencies": {
"@rocket.chat/core-services": "workspace:^",
"@rocket.chat/logger": "workspace:^",
"@rocket.chat/models": "workspace:^",
"@rocket.chat/omni-core": "workspace:^"
"@rocket.chat/omni-core": "workspace:^",
"mem": "^8.1.1",
"mongodb": "6.10.0"
},
"volta": {
"extends": "../../../package.json"
}
}

@ -1,5 +1,9 @@
import { isDepartmentCreationAvailablePatch } from './isDepartmentCreationAvailable';
import { applyDepartmentRestrictionsPatch } from './patches/applyDepartmentRestrictions';
export function patchOmniCore(): void {
isDepartmentCreationAvailablePatch();
applyDepartmentRestrictionsPatch();
}
export * from './units/getUnitsFromUser';

@ -0,0 +1,24 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { applyDepartmentRestrictions } from '@rocket.chat/omni-core';
import type { FilterOperators } from 'mongodb';
import { addQueryRestrictionsToDepartmentsModel } from '../units/addRoleBasedRestrictionsToDepartment';
import { hooksLogger } from '../utils/logger';
export const applyDepartmentRestrictionsPatch = () => {
applyDepartmentRestrictions.patch(
async (
prev: (query: FilterOperators<ILivechatDepartment>, userId: string) => FilterOperators<ILivechatDepartment>,
query: FilterOperators<ILivechatDepartment> = {},
userId: string,
) => {
if (!License.hasModule('livechat-enterprise')) {
return prev(query, userId);
}
hooksLogger.debug('Applying department query restrictions');
return addQueryRestrictionsToDepartmentsModel(query, userId);
},
);
};

@ -0,0 +1,297 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { FilterOperators } from 'mongodb';
import { addQueryRestrictionsToDepartmentsModel } from './addRoleBasedRestrictionsToDepartment';
import { getUnitsFromUser } from './getUnitsFromUser';
import { defaultLogger } from '../utils/logger';
// Mock dependencies
jest.mock('./getUnitsFromUser');
jest.mock('../utils/logger');
const mockedGetUnitsFromUser = jest.mocked(getUnitsFromUser);
const mockedLogger = jest.mocked(defaultLogger);
describe('addQueryRestrictionsToDepartmentsModel', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when getUnitsFromUser returns an array of units', () => {
it('should add unit restrictions to the query', async () => {
// Arrange
const userId = 'user123';
const units = ['unit1', 'unit2', 'unit3'];
const originalQuery: FilterOperators<ILivechatDepartment> = { name: 'test-department' };
mockedGetUnitsFromUser.mockResolvedValue(units);
// Act
const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId);
// Assert
expect(result).toEqual({
$and: [
{ name: 'test-department' },
{ type: { $ne: 'u' } },
{
$or: [{ ancestors: { $in: ['unit1', 'unit2', 'unit3'] } }, { _id: { $in: ['unit1', 'unit2', 'unit3'] } }],
},
],
});
expect(mockedGetUnitsFromUser).toHaveBeenCalledWith(userId);
expect(mockedLogger.debug).toHaveBeenCalledWith({
msg: 'Applying department query restrictions',
userId,
units,
});
});
it('should work with empty original query', async () => {
// Arrange
const userId = 'user456';
const units = ['unit4', 'unit5'];
mockedGetUnitsFromUser.mockResolvedValue(units);
// Act
const result = await addQueryRestrictionsToDepartmentsModel({}, userId);
// Assert
expect(result).toEqual({
$and: [
{},
{ type: { $ne: 'u' } },
{
$or: [{ ancestors: { $in: ['unit4', 'unit5'] } }, { _id: { $in: ['unit4', 'unit5'] } }],
},
],
});
});
it('should work with undefined original query', async () => {
// Arrange
const userId = 'user789';
const units = ['unit6'];
mockedGetUnitsFromUser.mockResolvedValue(units);
// Act
const result = await addQueryRestrictionsToDepartmentsModel(undefined, userId);
// Assert
expect(result).toEqual({
$and: [
{},
{ type: { $ne: 'u' } },
{
$or: [{ ancestors: { $in: ['unit6'] } }, { _id: { $in: ['unit6'] } }],
},
],
});
});
it('should handle single unit in array', async () => {
// Arrange
const userId = 'user101';
const units = ['single-unit'];
const originalQuery: FilterOperators<ILivechatDepartment> = { enabled: true };
mockedGetUnitsFromUser.mockResolvedValue(units);
// Act
const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId);
// Assert
expect(result).toEqual({
$and: [
{ enabled: true },
{ type: { $ne: 'u' } },
{
$or: [{ ancestors: { $in: ['single-unit'] } }, { _id: { $in: ['single-unit'] } }],
},
],
});
});
});
describe('when getUnitsFromUser returns non-array values', () => {
it('should not add unit restrictions when getUnitsFromUser returns null', async () => {
// Arrange
const userId = 'user202';
const originalQuery: FilterOperators<ILivechatDepartment> = { name: 'test' };
mockedGetUnitsFromUser.mockResolvedValue(undefined);
// Act
const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId);
// Assert
expect(result).toEqual({
$and: [{ name: 'test' }, { type: { $ne: 'u' } }],
});
expect(mockedLogger.debug).toHaveBeenCalledWith({
msg: 'Applying department query restrictions',
userId,
units: undefined,
});
});
it('should not add unit restrictions when getUnitsFromUser returns undefined', async () => {
// Arrange
const userId = 'user303';
const originalQuery: FilterOperators<ILivechatDepartment> = { active: true };
mockedGetUnitsFromUser.mockResolvedValue(undefined);
// Act
const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId);
// Assert
expect(result).toEqual({
$and: [{ active: true }, { type: { $ne: 'u' } }],
});
expect(mockedLogger.debug).toHaveBeenCalledWith({
msg: 'Applying department query restrictions',
userId,
units: undefined,
});
});
it('should not add unit restrictions when getUnitsFromUser returns a string', async () => {
// Arrange
const userId = 'user404';
const originalQuery: FilterOperators<ILivechatDepartment> = {};
mockedGetUnitsFromUser.mockResolvedValue('not-an-array' as any);
// Act
const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId);
// Assert
expect(result).toEqual({
$and: [{}, { type: { $ne: 'u' } }],
});
expect(mockedLogger.debug).toHaveBeenCalledWith({
msg: 'Applying department query restrictions',
userId,
units: 'not-an-array',
});
});
it('should not add unit restrictions when getUnitsFromUser returns empty array', async () => {
// Arrange
const userId = 'user505';
const originalQuery: FilterOperators<ILivechatDepartment> = { department: 'support' };
mockedGetUnitsFromUser.mockResolvedValue([]);
// Act
const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId);
// Assert
expect(result).toEqual({
$and: [
{ department: 'support' },
{ type: { $ne: 'u' } },
{
$or: [{ ancestors: { $in: [] } }, { _id: { $in: [] } }],
},
],
});
expect(mockedLogger.debug).toHaveBeenCalledWith({
msg: 'Applying department query restrictions',
userId,
units: [],
});
});
});
describe('error handling', () => {
it('should propagate errors from getUnitsFromUser', async () => {
// Arrange
const userId = 'user606';
const error = new Error('Database connection failed');
mockedGetUnitsFromUser.mockRejectedValue(error);
// Act & Assert
await expect(addQueryRestrictionsToDepartmentsModel({}, userId)).rejects.toThrow('Database connection failed');
expect(mockedGetUnitsFromUser).toHaveBeenCalledWith(userId);
expect(mockedLogger.debug).not.toHaveBeenCalled();
});
});
describe('complex query scenarios', () => {
it('should handle complex original query with nested conditions', async () => {
// Arrange
const userId = 'user707';
const units = ['unit-a', 'unit-b'];
const originalQuery: FilterOperators<ILivechatDepartment> = {
$or: [{ name: { $regex: 'support' } }, { enabled: true }],
createdAt: { $gte: new Date('2023-01-01') },
};
mockedGetUnitsFromUser.mockResolvedValue(units);
// Act
const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId);
// Assert
expect(result).toEqual({
$and: [
{
$or: [{ name: { $regex: 'support' } }, { enabled: true }],
createdAt: { $gte: new Date('2023-01-01') },
},
{ type: { $ne: 'u' } },
{
$or: [{ ancestors: { $in: ['unit-a', 'unit-b'] } }, { _id: { $in: ['unit-a', 'unit-b'] } }],
},
],
});
});
it('should always exclude departments with type "u"', async () => {
// Arrange
const userId = 'user808';
const units = ['unit-x'];
const originalQuery: FilterOperators<ILivechatDepartment> = { type: 'normal' };
mockedGetUnitsFromUser.mockResolvedValue(units);
// Act
const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId);
// Assert
expect(result.$and).toContainEqual({ type: { $ne: 'u' } });
});
});
describe('logging', () => {
it('should always call debug logger with correct parameters', async () => {
// Arrange
const userId = 'user909';
const units = ['unit-test'];
mockedGetUnitsFromUser.mockResolvedValue(units);
// Act
await addQueryRestrictionsToDepartmentsModel({}, userId);
// Assert
expect(mockedLogger.debug).toHaveBeenCalledTimes(1);
expect(mockedLogger.debug).toHaveBeenCalledWith({
msg: 'Applying department query restrictions',
userId: 'user909',
units: ['unit-test'],
});
});
});
});

@ -0,0 +1,17 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { FilterOperators } from 'mongodb';
import { getUnitsFromUser } from './getUnitsFromUser';
import { defaultLogger } from '../utils/logger';
export const addQueryRestrictionsToDepartmentsModel = async (originalQuery: FilterOperators<ILivechatDepartment> = {}, userId: string) => {
const query: FilterOperators<ILivechatDepartment> = { $and: [originalQuery, { type: { $ne: 'u' } }] };
const units = await getUnitsFromUser(userId);
if (Array.isArray(units)) {
query.$and.push({ $or: [{ ancestors: { $in: units } }, { _id: { $in: units } }] });
}
defaultLogger.debug({ msg: 'Applying department query restrictions', userId, units });
return query;
};

@ -0,0 +1,230 @@
import { Authorization } from '@rocket.chat/core-services';
import { LivechatUnit, LivechatDepartmentAgents } from '@rocket.chat/models';
import { getUnitsFromUser } from './getUnitsFromUser';
import { defaultLogger } from '../utils/logger';
// Mock the dependencies
jest.mock('@rocket.chat/core-services', () => ({
Authorization: {
hasAnyRole: jest.fn(),
},
}));
jest.mock('@rocket.chat/models', () => ({
LivechatUnit: {
findByMonitorId: jest.fn(),
countUnits: jest.fn(),
},
LivechatDepartmentAgents: {
findByAgentId: jest.fn(),
},
}));
jest.mock('mem', () => (fn: any) => fn);
jest.mock('../utils/logger');
const mockAuthorization = Authorization as jest.Mocked<typeof Authorization>;
const mockLivechatUnit = LivechatUnit as jest.Mocked<typeof LivechatUnit>;
const mockLivechatDepartmentAgents = LivechatDepartmentAgents as jest.Mocked<typeof LivechatDepartmentAgents>;
const mockLogger = defaultLogger as jest.Mocked<typeof defaultLogger>;
describe('getUnitsFromUser', () => {
beforeEach(() => {
jest.resetAllMocks();
// Setup default mock implementations
mockLivechatUnit.findByMonitorId.mockResolvedValue(['unit1', 'unit2']);
mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({
toArray: jest.fn().mockResolvedValue([{ departmentId: 'dept1' }, { departmentId: 'dept2' }]),
} as any);
mockLivechatUnit.countUnits.mockResolvedValue(5);
mockAuthorization.hasAnyRole.mockResolvedValue(false);
mockLogger.debug.mockImplementation(() => {
//
});
});
describe('when userId is not provided', () => {
it('should return undefined for null userId', async () => {
const result = await getUnitsFromUser(null as any);
expect(result).toBeUndefined();
});
it('should return undefined for undefined userId', async () => {
const result = await getUnitsFromUser(undefined);
expect(result).toBeUndefined();
});
it('should return undefined for empty string userId', async () => {
const result = await getUnitsFromUser('');
expect(result).toBeUndefined();
});
});
describe('when there are no units in the system', () => {
it('should return undefined', async () => {
mockLivechatUnit.countUnits.mockResolvedValue(0);
const result = await getUnitsFromUser('user123');
expect(result).toBeUndefined();
expect(mockLivechatUnit.countUnits).toHaveBeenCalled();
});
});
describe('when user has admin role', () => {
it('should return undefined for admin users', async () => {
mockAuthorization.hasAnyRole
.mockResolvedValueOnce(true) // admin/livechat-manager check
.mockResolvedValueOnce(false); // livechat-monitor/agent check
const result = await getUnitsFromUser('admin-user');
expect(mockLivechatUnit.countUnits).toHaveBeenCalled();
expect(result).toBeUndefined();
expect(mockAuthorization.hasAnyRole).toHaveBeenCalledWith('admin-user', ['admin', 'livechat-manager']);
});
});
describe('when user does not have required roles', () => {
it('should return undefined for users without livechat-monitor or livechat-agent roles', async () => {
mockAuthorization.hasAnyRole
.mockResolvedValueOnce(false) // admin/livechat-manager check
.mockResolvedValueOnce(false); // livechat-monitor/agent check
const result = await getUnitsFromUser('regular-user');
expect(result).toBeUndefined();
expect(mockAuthorization.hasAnyRole).toHaveBeenCalledWith('regular-user', ['admin', 'livechat-manager']);
expect(mockAuthorization.hasAnyRole).toHaveBeenCalledWith('regular-user', ['livechat-monitor', 'livechat-agent']);
});
});
describe('when user has livechat-manager role', () => {
it('should return undefined for livechat-manager users', async () => {
mockAuthorization.hasAnyRole
.mockResolvedValueOnce(true) // admin/livechat-manager check
.mockResolvedValueOnce(false); // livechat-monitor/agent check
const result = await getUnitsFromUser('manager-user');
expect(result).toBeUndefined();
expect(mockAuthorization.hasAnyRole).toHaveBeenCalledWith('manager-user', ['admin', 'livechat-manager']);
});
});
describe('when user has livechat-monitor role', () => {
it('should return combined units and departments', async () => {
const userId = 'monitor-user';
mockAuthorization.hasAnyRole
.mockResolvedValueOnce(false) // admin/livechat-manager check
.mockResolvedValueOnce(true); // livechat-monitor/agent check
mockLivechatUnit.findByMonitorId.mockResolvedValue(['unit1', 'unit2']);
mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({
toArray: jest.fn().mockResolvedValue([{ departmentId: 'dept1' }, { departmentId: 'dept2' }]),
} as any);
const result = await getUnitsFromUser(userId);
expect(result).toEqual(['unit1', 'unit2', 'dept1', 'dept2']);
expect(mockLivechatUnit.findByMonitorId).toHaveBeenCalledWith(userId);
expect(mockLivechatDepartmentAgents.findByAgentId).toHaveBeenCalledWith(userId);
expect(mockLogger.debug).toHaveBeenCalledWith({
msg: 'Calculating units for monitor',
user: userId,
unitsAndDepartments: ['unit1', 'unit2', 'dept1', 'dept2'],
});
});
});
describe('when user has livechat-agent role', () => {
it('should return combined units and departments', async () => {
const userId = 'agent-user';
mockAuthorization.hasAnyRole
.mockResolvedValueOnce(false) // admin/livechat-manager check
.mockResolvedValueOnce(true); // livechat-monitor/agent check
mockLivechatUnit.findByMonitorId.mockResolvedValue(['unit3']);
mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({
toArray: jest.fn().mockResolvedValue([{ departmentId: 'dept3' }, { departmentId: 'dept4' }]),
} as any);
const result = await getUnitsFromUser(userId);
expect(result).toEqual(['unit3', 'dept3', 'dept4']);
expect(mockLivechatUnit.findByMonitorId).toHaveBeenCalledWith(userId);
expect(mockLivechatDepartmentAgents.findByAgentId).toHaveBeenCalledWith(userId);
});
});
describe('edge cases', () => {
it('should handle empty units and departments arrays', async () => {
const userId = 'empty-user';
mockAuthorization.hasAnyRole
.mockResolvedValueOnce(false) // admin/livechat-manager check
.mockResolvedValueOnce(true); // livechat-monitor/agent check
mockLivechatUnit.findByMonitorId.mockResolvedValue([]);
mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({
toArray: jest.fn().mockResolvedValue([]),
} as any);
const result = await getUnitsFromUser(userId);
expect(result).toEqual([]);
});
it('should handle when only units are returned', async () => {
const userId = 'units-only-user';
mockAuthorization.hasAnyRole
.mockResolvedValueOnce(false) // admin/livechat-manager check
.mockResolvedValueOnce(true); // livechat-monitor/agent check
mockLivechatUnit.findByMonitorId.mockResolvedValue(['unit1', 'unit2']);
mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({
toArray: jest.fn().mockResolvedValue([]),
} as any);
const result = await getUnitsFromUser(userId);
expect(result).toEqual(['unit1', 'unit2']);
});
it('should handle when only departments are returned', async () => {
const userId = 'departments-only-user';
mockAuthorization.hasAnyRole
.mockResolvedValueOnce(false) // admin/livechat-manager check
.mockResolvedValueOnce(true); // livechat-monitor/agent check
mockLivechatUnit.findByMonitorId.mockResolvedValue([]);
mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({
toArray: jest.fn().mockResolvedValue([{ departmentId: 'dept1' }, { departmentId: 'dept2' }]),
} as any);
const result = await getUnitsFromUser(userId);
expect(result).toEqual(['dept1', 'dept2']);
});
});
describe('memoization', () => {
it('should work correctly with memoization disabled in tests', async () => {
const userId = 'test-user';
mockAuthorization.hasAnyRole
.mockResolvedValueOnce(false) // admin/livechat-manager check
.mockResolvedValueOnce(true); // livechat-monitor/agent check
mockLivechatUnit.findByMonitorId.mockResolvedValue(['unit1']);
mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({
toArray: jest.fn().mockResolvedValue([{ departmentId: 'dept1' }]),
} as any);
const result = await getUnitsFromUser(userId);
expect(result).toEqual(['unit1', 'dept1']);
expect(mockLivechatUnit.findByMonitorId).toHaveBeenCalledWith(userId);
expect(mockLivechatDepartmentAgents.findByAgentId).toHaveBeenCalledWith(userId);
});
});
});

@ -0,0 +1,48 @@
import { Authorization } from '@rocket.chat/core-services';
import { LivechatUnit, LivechatDepartmentAgents } from '@rocket.chat/models';
import mem from 'mem';
import { defaultLogger } from '../utils/logger';
async function getUnitsFromUserRoles(user: string): Promise<string[]> {
return LivechatUnit.findByMonitorId(user);
}
async function getDepartmentsFromUserRoles(user: string): Promise<string[]> {
return (await LivechatDepartmentAgents.findByAgentId(user).toArray()).map((department) => department.departmentId);
}
const memoizedGetUnitFromUserRoles = mem(getUnitsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 });
const memoizedGetDepartmentsFromUserRoles = mem(getDepartmentsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 });
async function hasUnits(): Promise<boolean> {
// @ts-expect-error - this prop is injected dynamically on ee license
return (await LivechatUnit.countUnits({ type: 'u' })) > 0;
}
// Units should't change really often, so we can cache the result
const memoizedHasUnits = mem(hasUnits, { maxAge: process.env.TEST_MODE ? 1 : 10000 });
export const getUnitsFromUser = async (userId?: string): Promise<string[] | undefined> => {
if (!userId) {
return;
}
if (!(await memoizedHasUnits())) {
return;
}
// TODO: we can combine these 2 calls into one single query
if (await Authorization.hasAnyRole(userId, ['admin', 'livechat-manager'])) {
return;
}
if (!(await Authorization.hasAnyRole(userId, ['livechat-monitor', 'livechat-agent']))) {
return;
}
const unitsAndDepartments = [...(await memoizedGetUnitFromUserRoles(userId)), ...(await memoizedGetDepartmentsFromUserRoles(userId))];
defaultLogger.debug({ msg: 'Calculating units for monitor', user: userId, unitsAndDepartments });
return unitsAndDepartments;
};

@ -0,0 +1,4 @@
import { Logger } from '@rocket.chat/logger';
export const defaultLogger = new Logger('OmniCore-ee');
export const hooksLogger = defaultLogger.section('hooks');

@ -1,4 +1,4 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import type { IRoom, IUser, IRole } from '@rocket.chat/core-typings';
export type RoomAccessValidator = (
room?: Pick<IRoom, '_id' | 't' | 'teamId' | 'prid'>,
@ -14,4 +14,5 @@ export interface IAuthorization {
canReadRoom: RoomAccessValidator;
canAccessRoomId(rid: IRoom['_id'], uid?: IUser['_id']): Promise<boolean>;
getUsersFromPublicRoles(): Promise<Pick<Required<IUser>, '_id' | 'username' | 'roles'>[]>;
hasAnyRole(userId: IUser['_id'], roleIds: IRole['_id'][], scope?: IRoom['_id']): Promise<boolean>;
}

@ -30,6 +30,10 @@
},
"dependencies": {
"@rocket.chat/models": "workspace:^",
"@rocket.chat/patch-injection": "workspace:^"
"@rocket.chat/patch-injection": "workspace:^",
"mongodb": "6.10.0"
},
"volta": {
"extends": "../../package.json"
}
}

@ -0,0 +1,7 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';
import type { FilterOperators } from 'mongodb';
export const applyDepartmentRestrictions = makeFunction(async (query: FilterOperators<ILivechatDepartment> = {}, _userId: string) => {
return query;
});

@ -1,2 +1,3 @@
export * from './isDepartmentCreationAvailable';
export * from './hooks/applyDepartmentRestrictions';
export * from './visitor/create';

Loading…
Cancel
Save