mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +00:00
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* fix: filter ACL principal details * test: type ACL permission pipeline assertions * test: add ACL permissions e2e coverage
426 lines
13 KiB
JavaScript
426 lines
13 KiB
JavaScript
const mongoose = require('mongoose');
|
|
|
|
const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
|
|
const mockGetTenantId = jest.fn();
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: mockLogger,
|
|
getTenantId: mockGetTenantId,
|
|
SYSTEM_TENANT_ID: '__SYSTEM__',
|
|
}));
|
|
|
|
const { AccessRoleIds, ResourceType, PrincipalType } =
|
|
jest.requireActual('librechat-data-provider');
|
|
|
|
jest.mock('librechat-data-provider', () => ({
|
|
...jest.requireActual('librechat-data-provider'),
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
enrichRemoteAgentPrincipals: jest.fn(),
|
|
backfillRemoteAgentPermissions: jest.fn(),
|
|
}));
|
|
|
|
const mockBulkUpdateResourcePermissions = jest.fn();
|
|
|
|
jest.mock('~/server/services/PermissionService', () => ({
|
|
bulkUpdateResourcePermissions: (...args) => mockBulkUpdateResourcePermissions(...args),
|
|
ensureGroupPrincipalExists: jest.fn(),
|
|
getEffectivePermissions: jest.fn(),
|
|
ensurePrincipalExists: jest.fn(),
|
|
getAvailableRoles: jest.fn(),
|
|
findAccessibleResources: jest.fn(),
|
|
getResourcePermissionsMap: jest.fn(),
|
|
}));
|
|
|
|
const mockRemoveAgentFromUserFavorites = jest.fn();
|
|
|
|
jest.mock('~/models', () => ({
|
|
aggregateAclEntries: jest.fn(),
|
|
searchPrincipals: jest.fn(),
|
|
sortPrincipalsByRelevance: jest.fn(),
|
|
calculateRelevanceScore: jest.fn(),
|
|
removeAgentFromUserFavorites: (...args) => mockRemoveAgentFromUserFavorites(...args),
|
|
}));
|
|
|
|
jest.mock('~/server/services/GraphApiService', () => ({
|
|
entraIdPrincipalFeatureEnabled: jest.fn(() => false),
|
|
searchEntraIdPrincipals: jest.fn(),
|
|
}));
|
|
|
|
const db = require('~/models');
|
|
const {
|
|
updateResourcePermissions,
|
|
searchPrincipals,
|
|
getResourcePermissions,
|
|
} = require('../PermissionsController');
|
|
|
|
const createMockReq = (overrides = {}) => ({
|
|
params: { resourceType: ResourceType.AGENT, resourceId: '507f1f77bcf86cd799439011' },
|
|
body: { updated: [], removed: [], public: false },
|
|
user: { id: 'user-1', role: 'USER' },
|
|
headers: { authorization: '' },
|
|
...overrides,
|
|
});
|
|
|
|
const createMockRes = () => {
|
|
const res = {};
|
|
res.status = jest.fn().mockReturnValue(res);
|
|
res.json = jest.fn().mockReturnValue(res);
|
|
return res;
|
|
};
|
|
|
|
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
|
|
|
describe('PermissionsController', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockGetTenantId.mockReturnValue(undefined);
|
|
});
|
|
|
|
describe('searchPrincipals', () => {
|
|
beforeEach(() => {
|
|
db.searchPrincipals.mockResolvedValue([]);
|
|
db.calculateRelevanceScore.mockReturnValue(50);
|
|
db.sortPrincipalsByRelevance.mockImplementation((results) => results);
|
|
});
|
|
|
|
it('rejects non-string query parameters', async () => {
|
|
const req = createMockReq({
|
|
query: { q: ['alice'] },
|
|
});
|
|
const res = createMockRes();
|
|
|
|
await searchPrincipals(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: 'Query parameter "q" is required and must not be empty',
|
|
});
|
|
expect(db.searchPrincipals).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('searches with the trimmed literal query', async () => {
|
|
db.searchPrincipals.mockResolvedValue([
|
|
{
|
|
id: 'user-1',
|
|
type: PrincipalType.USER,
|
|
name: 'Regex [invalid User',
|
|
source: 'local',
|
|
},
|
|
]);
|
|
|
|
const req = createMockReq({
|
|
query: { q: ' [invalid ', limit: '5', types: PrincipalType.USER },
|
|
});
|
|
const res = createMockRes();
|
|
|
|
await searchPrincipals(req, res);
|
|
|
|
expect(db.searchPrincipals).toHaveBeenCalledWith('[invalid', 5, [PrincipalType.USER]);
|
|
expect(db.calculateRelevanceScore).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: 'Regex [invalid User' }),
|
|
'[invalid',
|
|
);
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
query: '[invalid',
|
|
limit: 5,
|
|
count: 1,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not expose internal error details on search failures', async () => {
|
|
db.searchPrincipals.mockRejectedValue(new Error('database failure with internal detail'));
|
|
|
|
const req = createMockReq({
|
|
query: { q: 'alice' },
|
|
});
|
|
const res = createMockRes();
|
|
|
|
await searchPrincipals(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(500);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: 'Failed to search principals',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getResourcePermissions — principal details', () => {
|
|
const currentTenantId = 'tenant-a';
|
|
const otherTenantId = 'tenant-b';
|
|
const userId = new mongoose.Types.ObjectId();
|
|
const groupId = new mongoose.Types.ObjectId();
|
|
|
|
it('omits joined user and group details outside the current request context', async () => {
|
|
mockGetTenantId.mockReturnValue(currentTenantId);
|
|
db.aggregateAclEntries.mockResolvedValue([
|
|
{
|
|
principalType: PrincipalType.USER,
|
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
|
userInfo: {
|
|
_id: userId,
|
|
tenantId: otherTenantId,
|
|
name: 'Outside User',
|
|
email: 'outside-user@example.com',
|
|
avatar: 'outside-user.png',
|
|
},
|
|
},
|
|
{
|
|
principalType: PrincipalType.GROUP,
|
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
|
groupInfo: {
|
|
_id: groupId,
|
|
tenantId: otherTenantId,
|
|
name: 'Outside Group',
|
|
email: 'outside-group@example.com',
|
|
avatar: 'outside-group.png',
|
|
},
|
|
},
|
|
{
|
|
principalType: PrincipalType.PUBLIC,
|
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
|
},
|
|
]);
|
|
|
|
const req = createMockReq();
|
|
const res = createMockRes();
|
|
|
|
await getResourcePermissions(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: req.params.resourceId,
|
|
principals: [],
|
|
public: true,
|
|
publicAccessRoleId: AccessRoleIds.AGENT_VIEWER,
|
|
});
|
|
expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('outside-user@example.com');
|
|
expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('outside-group@example.com');
|
|
});
|
|
|
|
it('includes joined user and group details in the current request context', async () => {
|
|
mockGetTenantId.mockReturnValue(currentTenantId);
|
|
db.aggregateAclEntries.mockResolvedValue([
|
|
{
|
|
principalType: PrincipalType.USER,
|
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
|
userInfo: {
|
|
_id: userId,
|
|
tenantId: currentTenantId,
|
|
name: 'Current User',
|
|
email: 'current-user@example.com',
|
|
avatar: 'current-user.png',
|
|
},
|
|
},
|
|
{
|
|
principalType: PrincipalType.GROUP,
|
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
|
groupInfo: {
|
|
_id: groupId,
|
|
tenantId: currentTenantId,
|
|
name: 'Current Group',
|
|
email: 'current-group@example.com',
|
|
avatar: 'current-group.png',
|
|
},
|
|
},
|
|
]);
|
|
|
|
const req = createMockReq();
|
|
const res = createMockRes();
|
|
|
|
await getResourcePermissions(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(res.json.mock.calls[0][0].principals).toEqual([
|
|
expect.objectContaining({
|
|
type: PrincipalType.USER,
|
|
id: userId.toString(),
|
|
email: 'current-user@example.com',
|
|
}),
|
|
expect.objectContaining({
|
|
type: PrincipalType.GROUP,
|
|
id: groupId.toString(),
|
|
email: 'current-group@example.com',
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('updateResourcePermissions — favorites cleanup', () => {
|
|
const agentObjectId = new mongoose.Types.ObjectId().toString();
|
|
const revokedUserId = new mongoose.Types.ObjectId().toString();
|
|
|
|
beforeEach(() => {
|
|
mockBulkUpdateResourcePermissions.mockResolvedValue({
|
|
granted: [],
|
|
updated: [],
|
|
revoked: [{ type: PrincipalType.USER, id: revokedUserId, name: 'Revoked User' }],
|
|
errors: [],
|
|
});
|
|
|
|
mockRemoveAgentFromUserFavorites.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('removes agent from revoked users favorites on AGENT resource type', async () => {
|
|
const req = createMockReq({
|
|
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
|
|
body: {
|
|
updated: [],
|
|
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
|
|
public: false,
|
|
},
|
|
});
|
|
const res = createMockRes();
|
|
|
|
await updateResourcePermissions(req, res);
|
|
await flushPromises();
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [revokedUserId]);
|
|
});
|
|
|
|
it('removes agent from revoked users favorites on REMOTE_AGENT resource type', async () => {
|
|
const req = createMockReq({
|
|
params: { resourceType: ResourceType.REMOTE_AGENT, resourceId: agentObjectId },
|
|
body: {
|
|
updated: [],
|
|
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
|
|
public: false,
|
|
},
|
|
});
|
|
const res = createMockRes();
|
|
|
|
await updateResourcePermissions(req, res);
|
|
await flushPromises();
|
|
|
|
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [revokedUserId]);
|
|
});
|
|
|
|
it('uses results.revoked (validated) not raw request payload', async () => {
|
|
const validId = new mongoose.Types.ObjectId().toString();
|
|
const invalidId = 'not-a-valid-id';
|
|
|
|
mockBulkUpdateResourcePermissions.mockResolvedValue({
|
|
granted: [],
|
|
updated: [],
|
|
revoked: [{ type: PrincipalType.USER, id: validId }],
|
|
errors: [{ principal: { type: PrincipalType.USER, id: invalidId }, error: 'Invalid ID' }],
|
|
});
|
|
|
|
const req = createMockReq({
|
|
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
|
|
body: {
|
|
updated: [],
|
|
removed: [
|
|
{ type: PrincipalType.USER, id: validId },
|
|
{ type: PrincipalType.USER, id: invalidId },
|
|
],
|
|
public: false,
|
|
},
|
|
});
|
|
const res = createMockRes();
|
|
|
|
await updateResourcePermissions(req, res);
|
|
await flushPromises();
|
|
|
|
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [validId]);
|
|
});
|
|
|
|
it('skips cleanup when no USER principals are revoked', async () => {
|
|
mockBulkUpdateResourcePermissions.mockResolvedValue({
|
|
granted: [],
|
|
updated: [],
|
|
revoked: [{ type: PrincipalType.GROUP, id: 'group-1' }],
|
|
errors: [],
|
|
});
|
|
|
|
const req = createMockReq({
|
|
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
|
|
body: {
|
|
updated: [],
|
|
removed: [{ type: PrincipalType.GROUP, id: 'group-1' }],
|
|
public: false,
|
|
},
|
|
});
|
|
const res = createMockRes();
|
|
|
|
await updateResourcePermissions(req, res);
|
|
await flushPromises();
|
|
|
|
expect(mockRemoveAgentFromUserFavorites).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips cleanup for non-agent resource types', async () => {
|
|
mockBulkUpdateResourcePermissions.mockResolvedValue({
|
|
granted: [],
|
|
updated: [],
|
|
revoked: [{ type: PrincipalType.USER, id: revokedUserId }],
|
|
errors: [],
|
|
});
|
|
|
|
const req = createMockReq({
|
|
params: { resourceType: ResourceType.PROMPTGROUP, resourceId: agentObjectId },
|
|
body: {
|
|
updated: [],
|
|
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
|
|
public: false,
|
|
},
|
|
});
|
|
const res = createMockRes();
|
|
|
|
await updateResourcePermissions(req, res);
|
|
await flushPromises();
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(mockRemoveAgentFromUserFavorites).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles agent not found gracefully', async () => {
|
|
mockRemoveAgentFromUserFavorites.mockResolvedValue(undefined);
|
|
|
|
const req = createMockReq({
|
|
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
|
|
body: {
|
|
updated: [],
|
|
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
|
|
public: false,
|
|
},
|
|
});
|
|
const res = createMockRes();
|
|
|
|
await updateResourcePermissions(req, res);
|
|
await flushPromises();
|
|
|
|
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalled();
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
});
|
|
|
|
it('logs error when removeAgentFromUserFavorites fails without blocking response', async () => {
|
|
mockRemoveAgentFromUserFavorites.mockRejectedValue(new Error('DB connection lost'));
|
|
|
|
const req = createMockReq({
|
|
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
|
|
body: {
|
|
updated: [],
|
|
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
|
|
public: false,
|
|
},
|
|
});
|
|
const res = createMockRes();
|
|
|
|
await updateResourcePermissions(req, res);
|
|
await flushPromises();
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
'[removeRevokedAgentFromFavorites] Error cleaning up favorites',
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
});
|
|
});
|