LibreChat/api/server/controllers/__tests__/PermissionsController.spec.js
Danny Avila c374d08b64
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 (#13524)
* fix: filter ACL principal details

* test: type ACL permission pipeline assertions

* test: add ACL permissions e2e coverage
2026-06-05 19:06:41 -04:00

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),
);
});
});
});