LibreChat/packages/data-schemas/src/methods/userGroup.methods.spec.ts
Danny Avila 74474815aa
🛂 feat: Role as Permission Principal Type
WIP: Role as Permission Principal Type

WIP: add user role check optimization to user principal check, update type comparisons

WIP: cover edge cases for string vs ObjectId handling in permission granting and checking

chore: Update people picker access middleware to use PrincipalType constants

feat: Enhance people picker access control to include roles permissions

chore: add missing default role schema values for people picker perms, cleanup typing

feat: Enhance PeoplePicker component with role-specific UI and localization updates

chore: Add missing `VIEW_ROLES` permission to role schema
2025-08-11 19:00:52 -04:00

620 lines
19 KiB
TypeScript

import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type * as t from '~/types';
import { createUserGroupMethods } from './userGroup';
import groupSchema from '~/schema/group';
import userSchema from '~/schema/user';
/** Mocking logger */
jest.mock('~/config/winston', () => ({
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
let mongoServer: MongoMemoryServer;
let Group: mongoose.Model<t.IGroup>;
let User: mongoose.Model<t.IUser>;
let methods: ReturnType<typeof createUserGroupMethods>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
/** Register models */
Group = mongoose.models.Group || mongoose.model<t.IGroup>('Group', groupSchema);
User = mongoose.models.User || mongoose.model<t.IUser>('User', userSchema);
/** Initialize methods */
methods = createUserGroupMethods(mongoose);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
});
describe('UserGroup Methods - Detailed Tests', () => {
describe('findGroupById', () => {
test('should find group by ObjectId', async () => {
const group = await Group.create({
name: 'Test Group',
source: 'local',
memberIds: [],
});
const found = await methods.findGroupById(group._id as mongoose.Types.ObjectId);
expect(found).toBeDefined();
expect(found?._id.toString()).toBe(group._id.toString());
expect(found?.name).toBe('Test Group');
});
test('should find group by string ID', async () => {
const group = await Group.create({
name: 'Test Group',
source: 'local',
memberIds: [],
});
const found = await methods.findGroupById(group._id as mongoose.Types.ObjectId);
expect(found).toBeDefined();
expect(found?._id.toString()).toBe(group._id.toString());
});
test('should apply projection correctly', async () => {
const group = await Group.create({
name: 'Test Group',
source: 'local',
description: 'Test Description',
memberIds: ['user1', 'user2'],
});
const found = await methods.findGroupById(group._id as mongoose.Types.ObjectId, {
name: 1,
});
expect(found).toBeDefined();
expect(found?.name).toBe('Test Group');
expect(found?.description).toBeUndefined();
expect(found?.memberIds).toBeUndefined();
});
test('should return null for non-existent group', async () => {
const fakeId = new mongoose.Types.ObjectId();
const found = await methods.findGroupById(fakeId as mongoose.Types.ObjectId);
expect(found).toBeNull();
});
});
describe('findGroupByExternalId', () => {
test('should find group by external ID and source', async () => {
await Group.create({
name: 'Entra Group',
source: 'entra',
idOnTheSource: 'entra-123',
memberIds: [],
});
const found = await methods.findGroupByExternalId('entra-123', 'entra');
expect(found).toBeDefined();
expect(found?.idOnTheSource).toBe('entra-123');
expect(found?.source).toBe('entra');
});
test('should not find group with wrong source', async () => {
await Group.create({
name: 'Entra Group',
source: 'entra',
idOnTheSource: 'entra-123',
memberIds: [],
});
const found = await methods.findGroupByExternalId('entra-123', 'local');
expect(found).toBeNull();
});
test('should handle multiple groups with same external ID but different sources', async () => {
const id = 'shared-id';
await Group.create({
name: 'Entra Group',
source: 'entra',
idOnTheSource: id,
memberIds: [],
});
await Group.create({
name: 'Local Group',
source: 'local',
memberIds: [],
});
const entraGroup = await methods.findGroupByExternalId(id, 'entra');
const localGroup = await methods.findGroupByExternalId(id, 'local');
expect(entraGroup?.name).toBe('Entra Group');
expect(localGroup).toBeNull(); // local groups don't use idOnTheSource by default
});
});
describe('findGroupsByNamePattern', () => {
beforeEach(async () => {
await Group.create([
{ name: 'Engineering Team', source: 'local', memberIds: [] },
{ name: 'Engineering Managers', source: 'local', memberIds: [] },
{ name: 'Marketing Team', source: 'local', memberIds: [] },
{
name: 'Remote Engineering',
source: 'entra',
idOnTheSource: 'entra-remote-eng',
memberIds: [],
},
]);
});
test('should find groups by name pattern', async () => {
const groups = await methods.findGroupsByNamePattern('Engineering');
expect(groups).toHaveLength(3);
expect(groups.every((g) => g.name.includes('Engineering'))).toBe(true);
});
test('should respect case insensitive search', async () => {
const groups = await methods.findGroupsByNamePattern('engineering');
expect(groups).toHaveLength(3);
});
test('should filter by source when provided', async () => {
const groups = await methods.findGroupsByNamePattern('Engineering', 'local');
expect(groups).toHaveLength(2);
expect(groups.every((g) => g.source === 'local')).toBe(true);
});
test('should respect limit parameter', async () => {
const groups = await methods.findGroupsByNamePattern('Engineering', null, 2);
expect(groups).toHaveLength(2);
});
test('should return empty array for no matches', async () => {
const groups = await methods.findGroupsByNamePattern('NonExistent');
expect(groups).toEqual([]);
});
});
describe('findGroupsByMemberId', () => {
let user1: mongoose.HydratedDocument<t.IUser>;
beforeEach(async () => {
user1 = await User.create({
name: 'User 1',
email: 'user1@test.com',
provider: 'local',
});
});
test('should find groups by member ObjectId', async () => {
await Group.create([
{
name: 'Group 1',
source: 'local',
memberIds: [(user1._id as mongoose.Types.ObjectId).toString(), 'other-user'],
},
{
name: 'Group 2',
source: 'local',
memberIds: [(user1._id as mongoose.Types.ObjectId).toString()],
},
{
name: 'Group 3',
source: 'local',
memberIds: ['other-user'],
},
]);
const groups = await methods.findGroupsByMemberId(user1._id as mongoose.Types.ObjectId);
expect(groups).toHaveLength(2);
expect(groups.map((g) => g.name).sort()).toEqual(['Group 1', 'Group 2']);
});
test('should find groups by member string ID', async () => {
await Group.create([
{
name: 'Group 1',
source: 'local',
memberIds: [(user1._id as mongoose.Types.ObjectId).toString()],
},
]);
const groups = await methods.findGroupsByMemberId(user1._id as mongoose.Types.ObjectId);
expect(groups).toHaveLength(1);
expect(groups[0].name).toBe('Group 1');
});
test('should return empty array for user with no groups', async () => {
const groups = await methods.findGroupsByMemberId(user1._id as mongoose.Types.ObjectId);
expect(groups).toEqual([]);
});
});
describe('createGroup', () => {
test('should create a group with all fields', async () => {
const groupData: Partial<t.IGroup> = {
name: 'New Group',
source: 'local',
description: 'A test group',
email: 'group@test.com',
avatar: 'avatar-url',
memberIds: ['user1', 'user2'],
};
const group = await methods.createGroup(groupData);
expect(group).toBeDefined();
expect(group.name).toBe(groupData.name);
expect(group.source).toBe(groupData.source);
expect(group.description).toBe(groupData.description);
expect(group.email).toBe(groupData.email);
expect(group.avatar).toBe(groupData.avatar);
expect(group.memberIds).toEqual(groupData.memberIds);
});
test('should create group with minimal data', async () => {
const group = await methods.createGroup({
name: 'Minimal Group',
});
expect(group).toBeDefined();
expect(group.name).toBe('Minimal Group');
expect(group.source).toBe('local'); // default
expect(group.memberIds).toEqual([]); // default
});
});
describe('upsertGroupByExternalId', () => {
test('should create new group when not exists', async () => {
const group = await methods.upsertGroupByExternalId('new-external-id', 'entra', {
name: 'New External Group',
description: 'Created by upsert',
});
expect(group).toBeDefined();
expect(group?.idOnTheSource).toBe('new-external-id');
expect(group?.source).toBe('entra');
expect(group?.name).toBe('New External Group');
expect(group?.description).toBe('Created by upsert');
});
test('should update existing group', async () => {
// Create initial group
await Group.create({
name: 'Original Name',
source: 'entra',
idOnTheSource: 'existing-id',
description: 'Original description',
memberIds: ['user1'],
});
// Upsert with updates
const updated = await methods.upsertGroupByExternalId('existing-id', 'entra', {
name: 'Updated Name',
description: 'Updated description',
memberIds: ['user1', 'user2'],
});
expect(updated).toBeDefined();
expect(updated?.name).toBe('Updated Name');
expect(updated?.description).toBe('Updated description');
expect(updated?.memberIds).toEqual(['user1', 'user2']);
expect(updated?.idOnTheSource).toBe('existing-id'); // unchanged
});
test('should not update group from different source', async () => {
await Group.create({
name: 'Entra Group',
source: 'entra',
idOnTheSource: 'shared-id',
});
const result = await methods.upsertGroupByExternalId('shared-id', 'local', {
name: 'Azure Group',
});
// Should create new group
expect(result?.name).toBe('Azure Group');
expect(result?.source).toBe('local');
// Verify both exist
const groups = await Group.find({ idOnTheSource: 'shared-id' });
expect(groups).toHaveLength(2);
});
});
describe('addUserToGroup and removeUserFromGroup', () => {
let user: mongoose.HydratedDocument<t.IUser>;
let userWithExternal: mongoose.HydratedDocument<t.IUser>;
let group: mongoose.HydratedDocument<t.IGroup>;
beforeEach(async () => {
user = await User.create({
name: 'Test User',
email: 'user@test.com',
provider: 'local',
});
userWithExternal = await User.create({
name: 'External User',
email: 'external@test.com',
provider: 'entra',
idOnTheSource: 'external-123',
});
group = await Group.create({
name: 'Test Group',
source: 'local',
memberIds: [],
});
});
test('should add user to group using user ID', async () => {
const result = await methods.addUserToGroup(
user._id as mongoose.Types.ObjectId,
group._id as mongoose.Types.ObjectId,
);
expect(result.user).toBeDefined();
expect(result.group).toBeDefined();
expect(result.group?.memberIds).toContain((user._id as mongoose.Types.ObjectId).toString());
});
test('should add user to group using idOnTheSource if available', async () => {
const result = await methods.addUserToGroup(
userWithExternal._id as mongoose.Types.ObjectId,
group._id as mongoose.Types.ObjectId,
);
expect(result.group?.memberIds).toContain('external-123');
expect(result.group?.memberIds).not.toContain(
(userWithExternal._id as mongoose.Types.ObjectId).toString(),
);
});
test('should not duplicate user in group', async () => {
// Add user first time
await methods.addUserToGroup(
user._id as mongoose.Types.ObjectId,
group._id as mongoose.Types.ObjectId,
);
// Add same user again
const result = await methods.addUserToGroup(
user._id as mongoose.Types.ObjectId,
group._id as mongoose.Types.ObjectId,
);
expect(result.group?.memberIds).toHaveLength(1);
expect(result.group?.memberIds).toContain((user._id as mongoose.Types.ObjectId).toString());
});
test('should remove user from group', async () => {
// First add user
await methods.addUserToGroup(
user._id as mongoose.Types.ObjectId,
group._id as mongoose.Types.ObjectId,
);
// Then remove
const result = await methods.removeUserFromGroup(
user._id as mongoose.Types.ObjectId,
group._id as mongoose.Types.ObjectId,
);
expect(result.group?.memberIds).toHaveLength(0);
expect(result.group?.memberIds).not.toContain(
(user._id as mongoose.Types.ObjectId).toString(),
);
});
test('should handle removing user not in group', async () => {
const result = await methods.removeUserFromGroup(
user._id as mongoose.Types.ObjectId,
group._id as mongoose.Types.ObjectId,
);
expect(result.group?.memberIds).toHaveLength(0);
});
});
describe('getUserGroups', () => {
let user: mongoose.HydratedDocument<t.IUser>;
beforeEach(async () => {
user = await User.create({
name: 'Test User',
email: 'user@test.com',
provider: 'local',
});
});
test('should get all groups for a user', async () => {
// Create groups with user as member
await Group.create([
{
name: 'Group 1',
source: 'local',
memberIds: [(user._id as mongoose.Types.ObjectId).toString()],
},
{
name: 'Group 2',
source: 'local',
memberIds: [(user._id as mongoose.Types.ObjectId).toString(), 'other-user'],
},
{
name: 'Group 3',
source: 'local',
memberIds: ['other-user'],
},
]);
const groups = await methods.getUserGroups(user._id as mongoose.Types.ObjectId);
expect(groups).toHaveLength(2);
expect(groups.map((g) => g.name).sort()).toEqual(['Group 1', 'Group 2']);
});
test('should return empty array for user with no groups', async () => {
const groups = await methods.getUserGroups(user._id as mongoose.Types.ObjectId);
expect(groups).toEqual([]);
});
test('should handle user with idOnTheSource', async () => {
const externalUser = await User.create({
name: 'External User',
email: 'external@test.com',
provider: 'entra',
idOnTheSource: 'external-456',
});
await Group.create({
name: 'External Group',
source: 'entra',
idOnTheSource: 'entra-external-group',
memberIds: ['external-456'], // Using idOnTheSource
});
const groups = await methods.getUserGroups(externalUser._id as mongoose.Types.ObjectId);
expect(groups).toHaveLength(1);
expect(groups[0].name).toBe('External Group');
});
});
describe('syncUserEntraGroups', () => {
let user: mongoose.HydratedDocument<t.IUser>;
beforeEach(async () => {
user = await User.create({
name: 'Entra User',
email: 'entra@test.com',
provider: 'entra',
idOnTheSource: 'entra-user-123',
});
});
test('should create new groups and add user', async () => {
const entraGroups = [
{ id: 'group-1', name: 'Entra Group 1' },
{ id: 'group-2', name: 'Entra Group 2' },
];
const result = await methods.syncUserEntraGroups(
user._id as mongoose.Types.ObjectId,
entraGroups,
);
expect(result.user).toBeDefined();
expect(result.addedGroups).toHaveLength(2);
expect(result.removedGroups).toHaveLength(0);
// Verify groups were created
const groups = await Group.find({ source: 'entra' });
expect(groups).toHaveLength(2);
// Verify user is member of both
for (const group of groups) {
expect(group.memberIds).toContain('entra-user-123');
}
});
test('should remove user from groups not in sync list', async () => {
// Create existing groups
const group1 = await Group.create({
name: 'Keep Group',
source: 'entra',
idOnTheSource: 'keep-group',
memberIds: ['entra-user-123'],
});
const group2 = await Group.create({
name: 'Remove Group',
source: 'entra',
idOnTheSource: 'remove-group',
memberIds: ['entra-user-123'],
});
// Sync with only one group
const result = await methods.syncUserEntraGroups(user._id as mongoose.Types.ObjectId, [
{ id: 'keep-group', name: 'Keep Group' },
]);
expect(result.addedGroups).toHaveLength(0);
expect(result.removedGroups).toHaveLength(1);
// Verify membership
const keepGroup = await Group.findById(group1._id);
const removeGroup = await Group.findById(group2._id);
expect(keepGroup?.memberIds).toContain('entra-user-123');
expect(removeGroup?.memberIds).not.toContain('entra-user-123');
});
test('should not affect local groups', async () => {
// Create local group
const localGroup = await Group.create({
name: 'Local Group',
source: 'local',
memberIds: ['entra-user-123'],
});
// Sync entra groups
await methods.syncUserEntraGroups(user._id as mongoose.Types.ObjectId, [
{ id: 'entra-group', name: 'Entra Group' },
]);
// Verify local group unchanged
const savedLocalGroup = await Group.findById(localGroup._id);
expect(savedLocalGroup?.memberIds).toContain('entra-user-123');
});
test('should throw error for non-existent user', async () => {
const fakeId = new mongoose.Types.ObjectId();
await expect(methods.syncUserEntraGroups(fakeId, [])).rejects.toThrow('User not found');
});
});
describe('sortPrincipalsByRelevance', () => {
test('should sort principals by relevance score', async () => {
const principals = [
{ id: '1', name: 'Test User', type: 'user' as const, source: 'local' as const },
{ id: '2', name: 'Admin Test', type: 'user' as const, source: 'local' as const },
{ id: '3', name: 'Test Group', type: 'group' as const, source: 'local' as const },
];
// Store original query in closure or pass it through
const sorted = methods.sortPrincipalsByRelevance(principals);
// Since we can't pass the query directly, the method should maintain
// the original order or have been called in a context where it knows the query
expect(sorted).toBeDefined();
expect(sorted).toHaveLength(3);
});
});
});