| | import mongoose from 'mongoose'; |
| | import { PrincipalType } from 'librechat-data-provider'; |
| | 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'; |
| |
|
| | |
| | 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(); |
| | Group = mongoose.models.Group || mongoose.model<t.IGroup>('Group', groupSchema); |
| | User = mongoose.models.User || mongoose.model<t.IUser>('User', userSchema); |
| | methods = createUserGroupMethods(mongoose); |
| | await mongoose.connect(mongoUri); |
| | }); |
| |
|
| | afterAll(async () => { |
| | await mongoose.disconnect(); |
| | await mongoServer.stop(); |
| | }); |
| |
|
| | beforeEach(async () => { |
| | await mongoose.connection.dropDatabase(); |
| | }); |
| |
|
| | describe('User Group Methods Tests', () => { |
| | describe('Group Query Methods', () => { |
| | let testGroup: t.IGroup; |
| | let testUser: t.IUser; |
| |
|
| | beforeEach(async () => { |
| | |
| | testUser = await User.create({ |
| | name: 'Test User', |
| | email: 'test@example.com', |
| | password: 'password123', |
| | provider: 'local', |
| | }); |
| |
|
| | |
| | testGroup = await Group.create({ |
| | name: 'Test Group', |
| | source: 'local', |
| | memberIds: [(testUser._id as mongoose.Types.ObjectId).toString()], |
| | }); |
| |
|
| | |
| | }); |
| |
|
| | test('should find group by ID', async () => { |
| | const group = await methods.findGroupById(testGroup._id as mongoose.Types.ObjectId); |
| |
|
| | expect(group).toBeDefined(); |
| | expect(group?._id.toString()).toBe(testGroup._id.toString()); |
| | expect(group?.name).toBe(testGroup.name); |
| | }); |
| |
|
| | test('should find group by ID with specific projection', async () => { |
| | const group = await methods.findGroupById(testGroup._id as mongoose.Types.ObjectId, { |
| | name: 1, |
| | }); |
| |
|
| | expect(group).toBeDefined(); |
| | expect(group?._id).toBeDefined(); |
| | expect(group?.name).toBe(testGroup.name); |
| | expect(group?.memberIds).toBeUndefined(); |
| | }); |
| |
|
| | test('should find group by external ID', async () => { |
| | |
| | const entraGroup = await Group.create({ |
| | name: 'Entra Group', |
| | source: 'entra', |
| | idOnTheSource: 'entra-id-12345', |
| | }); |
| |
|
| | const group = await methods.findGroupByExternalId('entra-id-12345', 'entra'); |
| |
|
| | expect(group).toBeDefined(); |
| | expect(group?._id.toString()).toBe(entraGroup._id.toString()); |
| | expect(group?.idOnTheSource).toBe('entra-id-12345'); |
| | }); |
| |
|
| | test('should return null for non-existent external ID', async () => { |
| | const group = await methods.findGroupByExternalId('non-existent-id', 'entra'); |
| | expect(group).toBeNull(); |
| | }); |
| |
|
| | test('should find groups by name pattern', async () => { |
| | |
| | await Group.create({ name: 'Test Group 2', source: 'local' }); |
| | await Group.create({ name: 'Admin Group', source: 'local' }); |
| | await Group.create({ |
| | name: 'Test Entra Group', |
| | source: 'entra', |
| | idOnTheSource: 'entra-id-xyz', |
| | }); |
| |
|
| | |
| | const testGroups = await methods.findGroupsByNamePattern('Test'); |
| | expect(testGroups).toHaveLength(3); |
| |
|
| | |
| | const localTestGroups = await methods.findGroupsByNamePattern('Test', 'local'); |
| | expect(localTestGroups).toHaveLength(2); |
| |
|
| | const entraTestGroups = await methods.findGroupsByNamePattern('Test', 'entra'); |
| | expect(entraTestGroups).toHaveLength(1); |
| | }); |
| |
|
| | test('should respect limit parameter in name search', async () => { |
| | |
| | for (let i = 0; i < 10; i++) { |
| | await Group.create({ name: `Numbered Group ${i}`, source: 'local' }); |
| | } |
| |
|
| | const limitedGroups = await methods.findGroupsByNamePattern('Numbered', null, 5); |
| | expect(limitedGroups).toHaveLength(5); |
| | }); |
| |
|
| | test('should find groups by member ID', async () => { |
| | |
| | const group2 = await Group.create({ |
| | name: 'Second Group', |
| | source: 'local', |
| | memberIds: [(testUser._id as mongoose.Types.ObjectId).toString()], |
| | }); |
| |
|
| | const group3 = await Group.create({ |
| | name: 'Third Group', |
| | source: 'local', |
| | memberIds: [new mongoose.Types.ObjectId().toString()] , |
| | }); |
| |
|
| | const userGroups = await methods.findGroupsByMemberId( |
| | testUser._id as mongoose.Types.ObjectId, |
| | ); |
| | expect(userGroups).toHaveLength(2); |
| |
|
| | |
| | const groupIds = userGroups.map((g) => g._id.toString()); |
| | expect(groupIds).toContain(testGroup._id.toString()); |
| | expect(groupIds).toContain(group2._id.toString()); |
| | expect(groupIds).not.toContain(group3._id.toString()); |
| | }); |
| | }); |
| |
|
| | describe('Group Creation and Update Methods', () => { |
| | test('should create a new group', async () => { |
| | const groupData = { |
| | name: 'New Test Group', |
| | source: 'local' as const, |
| | }; |
| |
|
| | const group = await methods.createGroup(groupData); |
| |
|
| | expect(group).toBeDefined(); |
| | expect(group.name).toBe(groupData.name); |
| | expect(group.source).toBe(groupData.source); |
| |
|
| | |
| | const savedGroup = await Group.findById(group._id); |
| | expect(savedGroup).toBeDefined(); |
| | }); |
| |
|
| | test('should upsert a group by external ID (create new)', async () => { |
| | const groupData = { |
| | name: 'New Entra Group', |
| | idOnTheSource: 'new-entra-id', |
| | }; |
| |
|
| | const group = await methods.upsertGroupByExternalId(groupData.idOnTheSource, 'entra', { |
| | name: groupData.name, |
| | }); |
| |
|
| | expect(group).toBeDefined(); |
| | expect(group?.name).toBe(groupData.name); |
| | expect(group?.idOnTheSource).toBe(groupData.idOnTheSource); |
| | expect(group?.source).toBe('entra'); |
| |
|
| | |
| | const savedGroup = await Group.findOne({ idOnTheSource: 'new-entra-id' }); |
| | expect(savedGroup).toBeDefined(); |
| | }); |
| |
|
| | test('should upsert a group by external ID (update existing)', async () => { |
| | |
| | await Group.create({ |
| | name: 'Original Name', |
| | source: 'entra', |
| | idOnTheSource: 'existing-entra-id', |
| | }); |
| |
|
| | |
| | const updatedGroup = await methods.upsertGroupByExternalId('existing-entra-id', 'entra', { |
| | name: 'Updated Name', |
| | }); |
| |
|
| | expect(updatedGroup).toBeDefined(); |
| | expect(updatedGroup?.name).toBe('Updated Name'); |
| | expect(updatedGroup?.idOnTheSource).toBe('existing-entra-id'); |
| |
|
| | |
| | const savedGroup = await Group.findOne({ idOnTheSource: 'existing-entra-id' }); |
| | expect(savedGroup?.name).toBe('Updated Name'); |
| | }); |
| | }); |
| |
|
| | describe('User-Group Relationship Methods', () => { |
| | let testUser1: t.IUser; |
| | let testGroup: t.IGroup; |
| |
|
| | beforeEach(async () => { |
| | |
| | testUser1 = await User.create({ |
| | name: 'User One', |
| | email: 'user1@example.com', |
| | password: 'password123', |
| | provider: 'local', |
| | }); |
| |
|
| | |
| | testGroup = await Group.create({ |
| | name: 'Test Group', |
| | source: 'local', |
| | memberIds: [] , |
| | }); |
| | }); |
| |
|
| | test('should add user to group', async () => { |
| | const result = await methods.addUserToGroup( |
| | testUser1._id as mongoose.Types.ObjectId, |
| | testGroup._id as mongoose.Types.ObjectId, |
| | ); |
| |
|
| | |
| | expect(result).toBeDefined(); |
| | expect(result.user).toBeDefined(); |
| | expect(result.group).toBeDefined(); |
| |
|
| | |
| | const userIdOnTheSource = |
| | result.user.idOnTheSource || (testUser1._id as mongoose.Types.ObjectId).toString(); |
| | expect(result.group?.memberIds).toContain(userIdOnTheSource); |
| |
|
| | |
| | const updatedGroup = await Group.findById(testGroup._id); |
| | expect(updatedGroup?.memberIds).toContain(userIdOnTheSource); |
| | }); |
| |
|
| | test('should remove user from group', async () => { |
| | |
| | await methods.addUserToGroup( |
| | testUser1._id as mongoose.Types.ObjectId, |
| | testGroup._id as mongoose.Types.ObjectId, |
| | ); |
| |
|
| | |
| | const result = await methods.removeUserFromGroup( |
| | testUser1._id as mongoose.Types.ObjectId, |
| | testGroup._id as mongoose.Types.ObjectId, |
| | ); |
| |
|
| | |
| | expect(result).toBeDefined(); |
| | expect(result.user).toBeDefined(); |
| | expect(result.group).toBeDefined(); |
| |
|
| | |
| | const userIdOnTheSource = |
| | result.user.idOnTheSource || (testUser1._id as mongoose.Types.ObjectId).toString(); |
| | expect(result.group?.memberIds).not.toContain(userIdOnTheSource); |
| |
|
| | |
| | const updatedGroup = await Group.findById(testGroup._id); |
| | expect(updatedGroup?.memberIds).not.toContain(userIdOnTheSource); |
| | }); |
| |
|
| | test('should get all groups for a user', async () => { |
| | |
| | const group1 = await Group.create({ name: 'Group 1', source: 'local', memberIds: [] }); |
| | const group2 = await Group.create({ name: 'Group 2', source: 'local', memberIds: [] }); |
| |
|
| | await methods.addUserToGroup( |
| | testUser1._id as mongoose.Types.ObjectId, |
| | group1._id as mongoose.Types.ObjectId, |
| | ); |
| | await methods.addUserToGroup( |
| | testUser1._id as mongoose.Types.ObjectId, |
| | group2._id as mongoose.Types.ObjectId, |
| | ); |
| |
|
| | |
| | const userGroups = await methods.getUserGroups(testUser1._id as mongoose.Types.ObjectId); |
| |
|
| | expect(userGroups).toHaveLength(2); |
| | const groupIds = userGroups.map((g) => g._id.toString()); |
| | expect(groupIds).toContain(group1._id.toString()); |
| | expect(groupIds).toContain(group2._id.toString()); |
| | }); |
| |
|
| | test('should return empty array for getUserGroups when user has no groups', async () => { |
| | const userGroups = await methods.getUserGroups(testUser1._id as mongoose.Types.ObjectId); |
| | expect(userGroups).toEqual([]); |
| | }); |
| |
|
| | test('should get user principals', async () => { |
| | |
| | await methods.addUserToGroup( |
| | testUser1._id as mongoose.Types.ObjectId, |
| | testGroup._id as mongoose.Types.ObjectId, |
| | ); |
| |
|
| | |
| | const principals = await methods.getUserPrincipals({ |
| | userId: testUser1._id as mongoose.Types.ObjectId, |
| | }); |
| |
|
| | |
| | expect(principals).toHaveLength(4); |
| |
|
| | |
| | const userPrincipal = principals.find((p) => p.principalType === PrincipalType.USER); |
| | const groupPrincipal = principals.find((p) => p.principalType === PrincipalType.GROUP); |
| | const publicPrincipal = principals.find((p) => p.principalType === PrincipalType.PUBLIC); |
| |
|
| | expect(userPrincipal).toBeDefined(); |
| | expect(userPrincipal?.principalId?.toString()).toBe( |
| | (testUser1._id as mongoose.Types.ObjectId).toString(), |
| | ); |
| |
|
| | expect(groupPrincipal).toBeDefined(); |
| | expect(groupPrincipal?.principalId?.toString()).toBe(testGroup._id.toString()); |
| |
|
| | expect(publicPrincipal).toBeDefined(); |
| | expect(publicPrincipal?.principalId).toBeUndefined(); |
| | }); |
| |
|
| | test('should return user and public principals for non-existent user in getUserPrincipals', async () => { |
| | const nonExistentId = new mongoose.Types.ObjectId(); |
| | const principals = await methods.getUserPrincipals({ |
| | userId: nonExistentId, |
| | }); |
| |
|
| | |
| | expect(principals).toHaveLength(2); |
| | expect(principals[0].principalType).toBe(PrincipalType.USER); |
| | expect(principals[0].principalId?.toString()).toBe(nonExistentId.toString()); |
| | expect(principals[1].principalType).toBe(PrincipalType.PUBLIC); |
| | expect(principals[1].principalId).toBeUndefined(); |
| | }); |
| |
|
| | test('should convert string userId to ObjectId in getUserPrincipals', async () => { |
| | |
| | await methods.addUserToGroup( |
| | testUser1._id as mongoose.Types.ObjectId, |
| | testGroup._id as mongoose.Types.ObjectId, |
| | ); |
| |
|
| | |
| | const principals = await methods.getUserPrincipals({ |
| | userId: (testUser1._id as mongoose.Types.ObjectId).toString(), |
| | }); |
| |
|
| | |
| | expect(principals).toHaveLength(4); |
| |
|
| | |
| | const userPrincipal = principals.find((p) => p.principalType === PrincipalType.USER); |
| | expect(userPrincipal).toBeDefined(); |
| | expect(userPrincipal?.principalId).toBeInstanceOf(mongoose.Types.ObjectId); |
| | expect(userPrincipal?.principalId?.toString()).toBe( |
| | (testUser1._id as mongoose.Types.ObjectId).toString(), |
| | ); |
| |
|
| | |
| | const groupPrincipal = principals.find((p) => p.principalType === PrincipalType.GROUP); |
| | expect(groupPrincipal).toBeDefined(); |
| | expect(groupPrincipal?.principalId).toBeInstanceOf(mongoose.Types.ObjectId); |
| | expect(groupPrincipal?.principalId?.toString()).toBe(testGroup._id.toString()); |
| | }); |
| |
|
| | test('should include role principal as string in getUserPrincipals', async () => { |
| | |
| | const userWithRole = await User.create({ |
| | name: 'Admin User', |
| | email: 'admin@example.com', |
| | password: 'password123', |
| | provider: 'local', |
| | role: 'ADMIN', |
| | }); |
| |
|
| | |
| | const principals = await methods.getUserPrincipals({ |
| | userId: userWithRole._id as mongoose.Types.ObjectId, |
| | }); |
| |
|
| | |
| | expect(principals).toHaveLength(3); |
| |
|
| | |
| | const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); |
| | expect(rolePrincipal).toBeDefined(); |
| | expect(typeof rolePrincipal?.principalId).toBe('string'); |
| | expect(rolePrincipal?.principalId).toBe('ADMIN'); |
| | }); |
| | }); |
| |
|
| | describe('Entra ID Synchronization', () => { |
| | let testUser: t.IUser; |
| |
|
| | beforeEach(async () => { |
| | testUser = await User.create({ |
| | name: 'Entra User', |
| | email: 'entra@example.com', |
| | password: 'password123', |
| | provider: 'entra', |
| | idOnTheSource: 'entra-user-123', |
| | }); |
| | }); |
| |
|
| | |
| | test.skip('should sync Entra groups for a user (add new groups)', async () => { |
| | |
| | const entraGroups = [ |
| | { id: 'entra-group-1', name: 'Entra Group 1' }, |
| | { id: 'entra-group-2', name: 'Entra Group 2' }, |
| | ]; |
| |
|
| | const result = await methods.syncUserEntraGroups( |
| | testUser._id as mongoose.Types.ObjectId, |
| | entraGroups, |
| | ); |
| |
|
| | |
| | expect(result).toBeDefined(); |
| | expect(result.user).toBeDefined(); |
| | expect(result.addedGroups).toHaveLength(2); |
| | expect(result.removedGroups).toHaveLength(0); |
| |
|
| | |
| | const groups = await Group.find({ source: 'entra' }); |
| | expect(groups).toHaveLength(2); |
| |
|
| | |
| | const user = await User.findById(testUser._id); |
| | expect(user).toBeDefined(); |
| |
|
| | |
| | for (const group of groups) { |
| | expect(group.memberIds).toContain( |
| | testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(), |
| | ); |
| | } |
| | }); |
| |
|
| | test.skip('should sync Entra groups for a user (add and remove groups)', async () => { |
| | |
| | await Group.create({ |
| | name: 'Existing Group 1', |
| | source: 'entra', |
| | idOnTheSource: 'existing-1', |
| | memberIds: [testUser.idOnTheSource], |
| | }); |
| |
|
| | const existingGroup2 = await Group.create({ |
| | name: 'Existing Group 2', |
| | source: 'entra', |
| | idOnTheSource: 'existing-2', |
| | memberIds: [testUser.idOnTheSource], |
| | }); |
| |
|
| | |
| |
|
| | |
| | const entraGroups = [ |
| | { id: 'existing-1', name: 'Existing Group 1' } , |
| | { id: 'new-group', name: 'New Group' } , |
| | |
| | ]; |
| |
|
| | const result = await methods.syncUserEntraGroups( |
| | testUser._id as mongoose.Types.ObjectId, |
| | entraGroups, |
| | ); |
| |
|
| | |
| | expect(result).toBeDefined(); |
| | expect(result.addedGroups).toHaveLength(1); |
| | expect(result.removedGroups).toHaveLength(1); |
| |
|
| | |
| | const removedGroup = await Group.findById(existingGroup2._id); |
| | expect(removedGroup?.memberIds).toHaveLength(0); |
| |
|
| | |
| | const newGroup = await Group.findOne({ idOnTheSource: 'new-group' }); |
| | expect(newGroup).toBeDefined(); |
| | expect(newGroup?.memberIds).toContain( |
| | testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(), |
| | ); |
| | }); |
| |
|
| | test('should throw error for non-existent user in syncUserEntraGroups', async () => { |
| | const nonExistentId = new mongoose.Types.ObjectId(); |
| | const entraGroups = [{ id: 'some-id', name: 'Some Group' }]; |
| |
|
| | await expect(methods.syncUserEntraGroups(nonExistentId, entraGroups)).rejects.toThrow( |
| | 'User not found', |
| | ); |
| | }); |
| |
|
| | test.skip('should preserve local groups when syncing Entra groups', async () => { |
| | |
| | const localGroup = await Group.create({ |
| | name: 'Local Group', |
| | source: 'local', |
| | memberIds: [testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString()], |
| | }); |
| |
|
| | |
| |
|
| | |
| | const entraGroups = [{ id: 'entra-group', name: 'Entra Group' }]; |
| |
|
| | const result = await methods.syncUserEntraGroups( |
| | testUser._id as mongoose.Types.ObjectId, |
| | entraGroups, |
| | ); |
| |
|
| | |
| | expect(result).toBeDefined(); |
| |
|
| | |
| | const savedLocalGroup = await Group.findById(localGroup._id); |
| | expect(savedLocalGroup).toBeDefined(); |
| | expect(savedLocalGroup?.memberIds).toContain( |
| | testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(), |
| | ); |
| |
|
| | |
| | const entraGroup = await Group.findOne({ idOnTheSource: 'entra-group' }); |
| | expect(entraGroup).toBeDefined(); |
| | expect(entraGroup?.memberIds).toContain( |
| | testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(), |
| | ); |
| | }); |
| | }); |
| | }); |
| |
|