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