mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-22 15:46:33 +01:00
320 lines
9.7 KiB
JavaScript
320 lines
9.7 KiB
JavaScript
|
|
const mockGetMCPManager = jest.fn();
|
||
|
|
const mockInvalidateCachedTools = jest.fn();
|
||
|
|
|
||
|
|
jest.mock('~/config', () => ({
|
||
|
|
getMCPManager: (...args) => mockGetMCPManager(...args),
|
||
|
|
getFlowStateManager: jest.fn(),
|
||
|
|
getMCPServersRegistry: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/services/Config/getCachedTools', () => ({
|
||
|
|
invalidateCachedTools: (...args) => mockInvalidateCachedTools(...args),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/services/Config', () => ({
|
||
|
|
getAppConfig: jest.fn(),
|
||
|
|
getMCPServerTools: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
const mongoose = require('mongoose');
|
||
|
|
const { mcpServerSchema } = require('@librechat/data-schemas');
|
||
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||
|
|
const {
|
||
|
|
ResourceType,
|
||
|
|
AccessRoleIds,
|
||
|
|
PrincipalType,
|
||
|
|
PermissionBits,
|
||
|
|
} = require('librechat-data-provider');
|
||
|
|
const permissionService = require('~/server/services/PermissionService');
|
||
|
|
const { deleteUserMcpServers } = require('~/server/controllers/UserController');
|
||
|
|
const { AclEntry, AccessRole } = require('~/db/models');
|
||
|
|
|
||
|
|
let MCPServer;
|
||
|
|
|
||
|
|
describe('deleteUserMcpServers', () => {
|
||
|
|
let mongoServer;
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
mongoServer = await MongoMemoryServer.create();
|
||
|
|
const mongoUri = mongoServer.getUri();
|
||
|
|
MCPServer = mongoose.models.MCPServer || mongoose.model('MCPServer', mcpServerSchema);
|
||
|
|
await mongoose.connect(mongoUri);
|
||
|
|
|
||
|
|
await AccessRole.create({
|
||
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||
|
|
name: 'MCP Server Owner',
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
permBits:
|
||
|
|
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
|
||
|
|
});
|
||
|
|
|
||
|
|
await AccessRole.create({
|
||
|
|
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
|
||
|
|
name: 'MCP Server Viewer',
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
permBits: PermissionBits.VIEW,
|
||
|
|
});
|
||
|
|
}, 20000);
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
await mongoose.disconnect();
|
||
|
|
await mongoServer.stop();
|
||
|
|
});
|
||
|
|
|
||
|
|
beforeEach(async () => {
|
||
|
|
await MCPServer.deleteMany({});
|
||
|
|
await AclEntry.deleteMany({});
|
||
|
|
jest.clearAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should delete solely-owned MCP servers and their ACL entries', async () => {
|
||
|
|
const userId = new mongoose.Types.ObjectId();
|
||
|
|
|
||
|
|
const server = await MCPServer.create({
|
||
|
|
serverName: 'sole-owned-server',
|
||
|
|
config: { title: 'Test Server' },
|
||
|
|
author: userId,
|
||
|
|
});
|
||
|
|
|
||
|
|
await permissionService.grantPermission({
|
||
|
|
principalType: PrincipalType.USER,
|
||
|
|
principalId: userId,
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: server._id,
|
||
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||
|
|
grantedBy: userId,
|
||
|
|
});
|
||
|
|
|
||
|
|
mockGetMCPManager.mockReturnValue({
|
||
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
||
|
|
});
|
||
|
|
|
||
|
|
await deleteUserMcpServers(userId.toString());
|
||
|
|
|
||
|
|
expect(await MCPServer.findById(server._id)).toBeNull();
|
||
|
|
|
||
|
|
const aclEntries = await AclEntry.find({
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: server._id,
|
||
|
|
});
|
||
|
|
expect(aclEntries).toHaveLength(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should disconnect MCP sessions and invalidate tool cache before deletion', async () => {
|
||
|
|
const userId = new mongoose.Types.ObjectId();
|
||
|
|
const mockDisconnect = jest.fn().mockResolvedValue(undefined);
|
||
|
|
|
||
|
|
const server = await MCPServer.create({
|
||
|
|
serverName: 'session-server',
|
||
|
|
config: { title: 'Session Server' },
|
||
|
|
author: userId,
|
||
|
|
});
|
||
|
|
|
||
|
|
await permissionService.grantPermission({
|
||
|
|
principalType: PrincipalType.USER,
|
||
|
|
principalId: userId,
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: server._id,
|
||
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||
|
|
grantedBy: userId,
|
||
|
|
});
|
||
|
|
|
||
|
|
mockGetMCPManager.mockReturnValue({ disconnectUserConnection: mockDisconnect });
|
||
|
|
|
||
|
|
await deleteUserMcpServers(userId.toString());
|
||
|
|
|
||
|
|
expect(mockDisconnect).toHaveBeenCalledWith(userId.toString(), 'session-server');
|
||
|
|
expect(mockInvalidateCachedTools).toHaveBeenCalledWith({
|
||
|
|
userId: userId.toString(),
|
||
|
|
serverName: 'session-server',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should preserve multi-owned MCP servers', async () => {
|
||
|
|
const deletingUserId = new mongoose.Types.ObjectId();
|
||
|
|
const otherOwnerId = new mongoose.Types.ObjectId();
|
||
|
|
|
||
|
|
const soleServer = await MCPServer.create({
|
||
|
|
serverName: 'sole-server',
|
||
|
|
config: { title: 'Sole Server' },
|
||
|
|
author: deletingUserId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const multiServer = await MCPServer.create({
|
||
|
|
serverName: 'multi-server',
|
||
|
|
config: { title: 'Multi Server' },
|
||
|
|
author: deletingUserId,
|
||
|
|
});
|
||
|
|
|
||
|
|
await permissionService.grantPermission({
|
||
|
|
principalType: PrincipalType.USER,
|
||
|
|
principalId: deletingUserId,
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: soleServer._id,
|
||
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||
|
|
grantedBy: deletingUserId,
|
||
|
|
});
|
||
|
|
|
||
|
|
await permissionService.grantPermission({
|
||
|
|
principalType: PrincipalType.USER,
|
||
|
|
principalId: deletingUserId,
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: multiServer._id,
|
||
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||
|
|
grantedBy: deletingUserId,
|
||
|
|
});
|
||
|
|
await permissionService.grantPermission({
|
||
|
|
principalType: PrincipalType.USER,
|
||
|
|
principalId: otherOwnerId,
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: multiServer._id,
|
||
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||
|
|
grantedBy: otherOwnerId,
|
||
|
|
});
|
||
|
|
|
||
|
|
mockGetMCPManager.mockReturnValue({
|
||
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
||
|
|
});
|
||
|
|
|
||
|
|
await deleteUserMcpServers(deletingUserId.toString());
|
||
|
|
|
||
|
|
expect(await MCPServer.findById(soleServer._id)).toBeNull();
|
||
|
|
expect(await MCPServer.findById(multiServer._id)).not.toBeNull();
|
||
|
|
|
||
|
|
const soleAcl = await AclEntry.find({
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: soleServer._id,
|
||
|
|
});
|
||
|
|
expect(soleAcl).toHaveLength(0);
|
||
|
|
|
||
|
|
const multiAclOther = await AclEntry.find({
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: multiServer._id,
|
||
|
|
principalId: otherOwnerId,
|
||
|
|
});
|
||
|
|
expect(multiAclOther).toHaveLength(1);
|
||
|
|
expect(multiAclOther[0].permBits & PermissionBits.DELETE).toBeTruthy();
|
||
|
|
|
||
|
|
const multiAclDeleting = await AclEntry.find({
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: multiServer._id,
|
||
|
|
principalId: deletingUserId,
|
||
|
|
});
|
||
|
|
expect(multiAclDeleting).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should be a no-op when user has no owned MCP servers', async () => {
|
||
|
|
const userId = new mongoose.Types.ObjectId();
|
||
|
|
|
||
|
|
const otherUserId = new mongoose.Types.ObjectId();
|
||
|
|
const server = await MCPServer.create({
|
||
|
|
serverName: 'other-server',
|
||
|
|
config: { title: 'Other Server' },
|
||
|
|
author: otherUserId,
|
||
|
|
});
|
||
|
|
|
||
|
|
await permissionService.grantPermission({
|
||
|
|
principalType: PrincipalType.USER,
|
||
|
|
principalId: otherUserId,
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: server._id,
|
||
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||
|
|
grantedBy: otherUserId,
|
||
|
|
});
|
||
|
|
|
||
|
|
await deleteUserMcpServers(userId.toString());
|
||
|
|
|
||
|
|
expect(await MCPServer.findById(server._id)).not.toBeNull();
|
||
|
|
expect(mockGetMCPManager).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle gracefully when MCPServer model is not registered', async () => {
|
||
|
|
const originalModel = mongoose.models.MCPServer;
|
||
|
|
delete mongoose.models.MCPServer;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const userId = new mongoose.Types.ObjectId();
|
||
|
|
await expect(deleteUserMcpServers(userId.toString())).resolves.toBeUndefined();
|
||
|
|
} finally {
|
||
|
|
mongoose.models.MCPServer = originalModel;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should handle gracefully when MCPManager is not available', async () => {
|
||
|
|
const userId = new mongoose.Types.ObjectId();
|
||
|
|
|
||
|
|
const server = await MCPServer.create({
|
||
|
|
serverName: 'no-manager-server',
|
||
|
|
config: { title: 'No Manager Server' },
|
||
|
|
author: userId,
|
||
|
|
});
|
||
|
|
|
||
|
|
await permissionService.grantPermission({
|
||
|
|
principalType: PrincipalType.USER,
|
||
|
|
principalId: userId,
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: server._id,
|
||
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||
|
|
grantedBy: userId,
|
||
|
|
});
|
||
|
|
|
||
|
|
mockGetMCPManager.mockReturnValue(null);
|
||
|
|
|
||
|
|
await deleteUserMcpServers(userId.toString());
|
||
|
|
|
||
|
|
expect(await MCPServer.findById(server._id)).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should delete legacy MCP servers that have author but no ACL entries', async () => {
|
||
|
|
const legacyUserId = new mongoose.Types.ObjectId();
|
||
|
|
|
||
|
|
const legacyServer = await MCPServer.create({
|
||
|
|
serverName: 'legacy-server',
|
||
|
|
config: { title: 'Legacy Server' },
|
||
|
|
author: legacyUserId,
|
||
|
|
});
|
||
|
|
|
||
|
|
mockGetMCPManager.mockReturnValue({
|
||
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
||
|
|
});
|
||
|
|
|
||
|
|
await deleteUserMcpServers(legacyUserId.toString());
|
||
|
|
|
||
|
|
expect(await MCPServer.findById(legacyServer._id)).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('should delete both ACL-owned and legacy servers in one call', async () => {
|
||
|
|
const userId = new mongoose.Types.ObjectId();
|
||
|
|
|
||
|
|
const aclServer = await MCPServer.create({
|
||
|
|
serverName: 'acl-server',
|
||
|
|
config: { title: 'ACL Server' },
|
||
|
|
author: userId,
|
||
|
|
});
|
||
|
|
|
||
|
|
await permissionService.grantPermission({
|
||
|
|
principalType: PrincipalType.USER,
|
||
|
|
principalId: userId,
|
||
|
|
resourceType: ResourceType.MCPSERVER,
|
||
|
|
resourceId: aclServer._id,
|
||
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||
|
|
grantedBy: userId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const legacyServer = await MCPServer.create({
|
||
|
|
serverName: 'legacy-mixed-server',
|
||
|
|
config: { title: 'Legacy Mixed' },
|
||
|
|
author: userId,
|
||
|
|
});
|
||
|
|
|
||
|
|
mockGetMCPManager.mockReturnValue({
|
||
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
||
|
|
});
|
||
|
|
|
||
|
|
await deleteUserMcpServers(userId.toString());
|
||
|
|
|
||
|
|
expect(await MCPServer.findById(aclServer._id)).toBeNull();
|
||
|
|
expect(await MCPServer.findById(legacyServer._id)).toBeNull();
|
||
|
|
});
|
||
|
|
});
|