LibreChat/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js
Danny Avila 76e17ba701
🔧 refactor: Permission handling for Resource Sharing (#11283)
* 🔧 refactor: permission handling for public sharing

- Updated permission keys from SHARED_GLOBAL to SHARE across various files for consistency.
- Added public access configuration in librechat.example.yaml.
- Adjusted related tests and components to reflect the new permission structure.

* chore: Update default SHARE permission to false

* fix: Update SHARE permissions in tests and implementation

- Added SHARE permission handling for user and admin roles in permissions.spec.ts and permissions.ts.
- Updated expected permissions in tests to reflect new SHARE permission values for various permission types.

* fix: Handle undefined values in PeoplePickerAdminSettings component

- Updated the checked and value props of the Switch component to handle undefined values gracefully by defaulting to false. This ensures consistent behavior when the field value is not set.

* feat: Add CREATE permission handling for prompts and agents

- Introduced CREATE permission for user and admin roles in permissions.spec.ts and permissions.ts.
- Updated expected permissions in tests to include CREATE permission for various permission types.

* 🔧 refactor: Enhance permission handling for sharing dialog usability

* refactor: public sharing permissions for resources

- Added middleware to check SHARE_PUBLIC permissions for agents, prompts, and MCP servers.
- Updated interface configuration in librechat.example.yaml to include public sharing options.
- Enhanced components and hooks to support public sharing functionality.
- Adjusted tests to validate new permission handling for public sharing across various resource types.

* refactor: update Share2Icon styling in GenericGrantAccessDialog

* refactor: update Share2Icon size in GenericGrantAccessDialog for consistency

* refactor: improve layout and styling of Share2Icon in GenericGrantAccessDialog

* refactor: update Share2Icon size in GenericGrantAccessDialog for improved consistency

* chore: remove redundant public sharing option from People Picker

* refactor: add SHARE_PUBLIC permission handling in updateInterfacePermissions tests
2026-01-10 14:02:56 -05:00

627 lines
20 KiB
JavaScript

const mongoose = require('mongoose');
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
const { User, Role, AclEntry } = require('~/db/models');
const { createMCPServer } = require('~/models');
describe('canAccessMCPServerResource middleware', () => {
let mongoServer;
let req, res, next;
let testUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
await Role.create({
name: 'test-role',
permissions: {
MCP_SERVERS: {
USE: true,
CREATE: true,
SHARE: true,
},
},
});
// Create a test user
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});
req = {
user: { id: testUser._id, role: testUser.role },
params: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('middleware factory', () => {
test('should throw error if requiredPermission is not provided', () => {
expect(() => canAccessMCPServerResource({})).toThrow(
'canAccessMCPServerResource: requiredPermission is required and must be a number',
);
});
test('should throw error if requiredPermission is not a number', () => {
expect(() => canAccessMCPServerResource({ requiredPermission: '1' })).toThrow(
'canAccessMCPServerResource: requiredPermission is required and must be a number',
);
});
test('should throw error if requiredPermission is null', () => {
expect(() => canAccessMCPServerResource({ requiredPermission: null })).toThrow(
'canAccessMCPServerResource: requiredPermission is required and must be a number',
);
});
test('should create middleware with default resourceIdParam (serverName)', () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // Express middleware signature
});
test('should create middleware with custom resourceIdParam', () => {
const middleware = canAccessMCPServerResource({
requiredPermission: 2,
resourceIdParam: 'mcpId',
});
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3);
});
});
describe('permission checking with real MCP servers', () => {
test('should allow access when user is the MCP server author', async () => {
// Create an MCP server owned by the test user
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Test MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author (owner permissions)
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when user is not the author and has no ACL entry', async () => {
// Create an MCP server owned by a different user
const otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Other User MCP Server',
},
author: otherUser._id,
});
// Create ACL entry for the other user (owner)
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: otherUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this mcpServer',
});
});
test('should allow access when user has ACL entry with sufficient permissions', async () => {
// Create an MCP server owned by a different user
const otherUser = await User.create({
email: 'other2@example.com',
name: 'Other User 2',
username: 'otheruser2',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Shared MCP Server',
},
author: otherUser._id,
});
// Create ACL entry granting view permission to test user
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 1, // VIEW permission
grantedBy: otherUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when ACL permissions are insufficient', async () => {
// Create an MCP server owned by a different user
const otherUser = await User.create({
email: 'other3@example.com',
name: 'Other User 3',
username: 'otheruser3',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Limited Access MCP Server',
},
author: otherUser._id,
});
// Create ACL entry granting only view permission
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 1, // VIEW permission only
grantedBy: otherUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 2 }); // EDIT permission required
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this mcpServer',
});
});
test('should handle non-existent MCP server', async () => {
req.params.serverName = 'non-existent-mcp-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'mcpServer not found',
});
});
test('should use custom resourceIdParam', async () => {
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Custom Param MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.mcpId = mcpServer.serverName; // Using custom param name
const middleware = canAccessMCPServerResource({
requiredPermission: 1,
resourceIdParam: 'mcpId',
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('permission levels', () => {
let mcpServer;
beforeEach(async () => {
mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Permission Test MCP Server',
},
author: testUser._id,
});
// Create ACL entry with all permissions for the owner
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
});
test('should support view permission (1)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support edit permission (2)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 2 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support delete permission (4)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 4 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support share permission (8)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 8 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support combined permissions', async () => {
const viewAndEdit = 1 | 2; // 3
const middleware = canAccessMCPServerResource({ requiredPermission: viewAndEdit });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('integration with resolveMCPServerId', () => {
test('should resolve serverName to MongoDB ObjectId correctly', async () => {
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Integration Test MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
// Verify that req.resourceAccess was set correctly
expect(req.resourceAccess).toBeDefined();
expect(req.resourceAccess.resourceType).toBe(ResourceType.MCPSERVER);
expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer._id.toString());
expect(req.resourceAccess.customResourceId).toBe(mcpServer.serverName);
});
test('should work with MCP server CRUD operations', async () => {
// Create MCP server
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'CRUD Test MCP Server',
description: 'Testing integration',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
// Test view access
const viewMiddleware = canAccessMCPServerResource({ requiredPermission: 1 });
await viewMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
jest.clearAllMocks();
// Update the MCP server
const { updateMCPServer } = require('~/models');
await updateMCPServer(mcpServer.serverName, {
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'CRUD Test MCP Server',
description: 'Updated description',
},
});
// Test edit access
const editMiddleware = canAccessMCPServerResource({ requiredPermission: 2 });
await editMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle stdio type MCP server', async () => {
const mcpServer = await createMCPServer({
config: {
type: 'stdio',
command: 'node',
args: ['server.js'],
title: 'Stdio MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15,
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.resourceAccess.resourceInfo.config.type).toBe('stdio');
});
});
describe('authentication and authorization edge cases', () => {
test('should return 400 when serverName parameter is missing', async () => {
// Don't set req.params.serverName
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'Bad Request',
message: 'serverName is required',
});
});
test('should return 401 when user is not authenticated', async () => {
req.user = null;
req.params.serverName = 'some-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
});
test('should return 401 when user id is missing', async () => {
req.user = { role: 'test-role' }; // No id
req.params.serverName = 'some-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
});
test('should allow admin users to bypass permission checks', async () => {
const { SystemRoles } = require('librechat-data-provider');
// Create an MCP server owned by another user
const otherUser = await User.create({
email: 'owner@example.com',
name: 'Owner User',
username: 'owneruser',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Admin Test MCP Server',
},
author: otherUser._id,
});
// Set user as admin
req.user = { id: testUser._id, role: SystemRoles.ADMIN };
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 4 }); // DELETE permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('error handling', () => {
test('should handle server returning null gracefully (treated as not found)', async () => {
// When an MCP server is not found, findMCPServerById returns null
// which the middleware correctly handles as a 404
req.params.serverName = 'definitely-non-existent-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'mcpServer not found',
});
});
});
describe('multiple servers with same title', () => {
test('should handle MCP servers with auto-generated suffixes', async () => {
// Create multiple servers with the same title (will have different serverNames)
const mcpServer1 = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp1',
title: 'Duplicate Title',
},
author: testUser._id,
});
const mcpServer2 = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp2',
title: 'Duplicate Title',
},
author: testUser._id,
});
// Create ACL entries for both
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer1._id,
permBits: 15,
grantedBy: testUser._id,
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer2._id,
permBits: 15,
grantedBy: testUser._id,
});
// Verify they have different serverNames
expect(mcpServer1.serverName).toBe('duplicate-title');
expect(mcpServer2.serverName).toBe('duplicate-title-2');
// Test access to first server
req.params.serverName = mcpServer1.serverName;
const middleware1 = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware1(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer1._id.toString());
jest.clearAllMocks();
// Test access to second server
req.params.serverName = mcpServer2.serverName;
const middleware2 = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware2(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer2._id.toString());
});
});
});