mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-16 12:46:34 +01:00
410 lines
12 KiB
JavaScript
410 lines
12 KiB
JavaScript
|
|
jest.mock('@librechat/data-schemas', () => ({
|
||
|
|
logger: { error: jest.fn() },
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/services/PermissionService', () => ({
|
||
|
|
checkPermission: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/models/Agent', () => ({
|
||
|
|
getAgent: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
const { logger } = require('@librechat/data-schemas');
|
||
|
|
const { Constants, PermissionBits, ResourceType } = require('librechat-data-provider');
|
||
|
|
const { checkPermission } = require('~/server/services/PermissionService');
|
||
|
|
const { getAgent } = require('~/models/Agent');
|
||
|
|
const { filterFilesByAgentAccess, hasAccessToFilesViaAgent } = require('./permissions');
|
||
|
|
|
||
|
|
const AUTHOR_ID = 'author-user-id';
|
||
|
|
const USER_ID = 'viewer-user-id';
|
||
|
|
const AGENT_ID = 'agent_test-abc123';
|
||
|
|
const AGENT_MONGO_ID = 'mongo-agent-id';
|
||
|
|
|
||
|
|
function makeFile(file_id, user) {
|
||
|
|
return { file_id, user, filename: `${file_id}.txt` };
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeAgent(overrides = {}) {
|
||
|
|
return {
|
||
|
|
_id: AGENT_MONGO_ID,
|
||
|
|
id: AGENT_ID,
|
||
|
|
author: AUTHOR_ID,
|
||
|
|
tool_resources: {
|
||
|
|
file_search: { file_ids: ['attached-1', 'attached-2'] },
|
||
|
|
execute_code: { file_ids: ['attached-3'] },
|
||
|
|
},
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
jest.clearAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('filterFilesByAgentAccess', () => {
|
||
|
|
describe('early returns (no DB calls)', () => {
|
||
|
|
it('should return files unfiltered for ephemeral agentId', async () => {
|
||
|
|
const files = [makeFile('f1', 'other-user')];
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files,
|
||
|
|
userId: USER_ID,
|
||
|
|
agentId: Constants.EPHEMERAL_AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toBe(files);
|
||
|
|
expect(getAgent).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return files unfiltered for non-agent_ prefixed agentId', async () => {
|
||
|
|
const files = [makeFile('f1', 'other-user')];
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files,
|
||
|
|
userId: USER_ID,
|
||
|
|
agentId: 'custom-memory-id',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toBe(files);
|
||
|
|
expect(getAgent).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return files when userId is missing', async () => {
|
||
|
|
const files = [makeFile('f1', 'someone')];
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files,
|
||
|
|
userId: undefined,
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toBe(files);
|
||
|
|
expect(getAgent).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return files when agentId is missing', async () => {
|
||
|
|
const files = [makeFile('f1', 'someone')];
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files,
|
||
|
|
userId: USER_ID,
|
||
|
|
agentId: undefined,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toBe(files);
|
||
|
|
expect(getAgent).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return empty array when files is empty', async () => {
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files: [],
|
||
|
|
userId: USER_ID,
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toEqual([]);
|
||
|
|
expect(getAgent).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return undefined when files is nullish', async () => {
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files: null,
|
||
|
|
userId: USER_ID,
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toBeNull();
|
||
|
|
expect(getAgent).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('all files owned by userId', () => {
|
||
|
|
it('should return all files without calling getAgent', async () => {
|
||
|
|
const files = [makeFile('f1', USER_ID), makeFile('f2', USER_ID)];
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files,
|
||
|
|
userId: USER_ID,
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toEqual(files);
|
||
|
|
expect(getAgent).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('mixed owned and non-owned files', () => {
|
||
|
|
const ownedFile = makeFile('owned-1', USER_ID);
|
||
|
|
const sharedFile = makeFile('attached-1', AUTHOR_ID);
|
||
|
|
const unattachedFile = makeFile('not-attached', AUTHOR_ID);
|
||
|
|
|
||
|
|
it('should return owned + accessible non-owned files when user has VIEW', async () => {
|
||
|
|
getAgent.mockResolvedValue(makeAgent());
|
||
|
|
checkPermission.mockResolvedValue(true);
|
||
|
|
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files: [ownedFile, sharedFile, unattachedFile],
|
||
|
|
userId: USER_ID,
|
||
|
|
role: 'USER',
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toHaveLength(2);
|
||
|
|
expect(result.map((f) => f.file_id)).toContain('owned-1');
|
||
|
|
expect(result.map((f) => f.file_id)).toContain('attached-1');
|
||
|
|
expect(result.map((f) => f.file_id)).not.toContain('not-attached');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return only owned files when user lacks VIEW permission', async () => {
|
||
|
|
getAgent.mockResolvedValue(makeAgent());
|
||
|
|
checkPermission.mockResolvedValue(false);
|
||
|
|
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files: [ownedFile, sharedFile],
|
||
|
|
userId: USER_ID,
|
||
|
|
role: 'USER',
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toEqual([ownedFile]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return only owned files when agent is not found', async () => {
|
||
|
|
getAgent.mockResolvedValue(null);
|
||
|
|
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files: [ownedFile, sharedFile],
|
||
|
|
userId: USER_ID,
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toEqual([ownedFile]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return only owned files on DB error (fail-closed)', async () => {
|
||
|
|
getAgent.mockRejectedValue(new Error('DB connection lost'));
|
||
|
|
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files: [ownedFile, sharedFile],
|
||
|
|
userId: USER_ID,
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toEqual([ownedFile]);
|
||
|
|
expect(logger.error).toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('file with no user field', () => {
|
||
|
|
it('should treat file as non-owned and run through access check', async () => {
|
||
|
|
const noUserFile = makeFile('attached-1', undefined);
|
||
|
|
getAgent.mockResolvedValue(makeAgent());
|
||
|
|
checkPermission.mockResolvedValue(true);
|
||
|
|
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files: [noUserFile],
|
||
|
|
userId: USER_ID,
|
||
|
|
role: 'USER',
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(getAgent).toHaveBeenCalled();
|
||
|
|
expect(result).toEqual([noUserFile]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should exclude file with no user field when not attached to agent', async () => {
|
||
|
|
const noUserFile = makeFile('not-attached', null);
|
||
|
|
getAgent.mockResolvedValue(makeAgent());
|
||
|
|
checkPermission.mockResolvedValue(true);
|
||
|
|
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files: [noUserFile],
|
||
|
|
userId: USER_ID,
|
||
|
|
role: 'USER',
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toEqual([]);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('no owned files (all non-owned)', () => {
|
||
|
|
const file1 = makeFile('attached-1', AUTHOR_ID);
|
||
|
|
const file2 = makeFile('not-attached', AUTHOR_ID);
|
||
|
|
|
||
|
|
it('should return only attached files when user has VIEW', async () => {
|
||
|
|
getAgent.mockResolvedValue(makeAgent());
|
||
|
|
checkPermission.mockResolvedValue(true);
|
||
|
|
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files: [file1, file2],
|
||
|
|
userId: USER_ID,
|
||
|
|
role: 'USER',
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toEqual([file1]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return empty array when no VIEW permission', async () => {
|
||
|
|
getAgent.mockResolvedValue(makeAgent());
|
||
|
|
checkPermission.mockResolvedValue(false);
|
||
|
|
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files: [file1, file2],
|
||
|
|
userId: USER_ID,
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return empty array when agent not found', async () => {
|
||
|
|
getAgent.mockResolvedValue(null);
|
||
|
|
|
||
|
|
const result = await filterFilesByAgentAccess({
|
||
|
|
files: [file1],
|
||
|
|
userId: USER_ID,
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result).toEqual([]);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('hasAccessToFilesViaAgent', () => {
|
||
|
|
describe('agent not found', () => {
|
||
|
|
it('should return all-false map', async () => {
|
||
|
|
getAgent.mockResolvedValue(null);
|
||
|
|
|
||
|
|
const result = await hasAccessToFilesViaAgent({
|
||
|
|
userId: USER_ID,
|
||
|
|
fileIds: ['f1', 'f2'],
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.get('f1')).toBe(false);
|
||
|
|
expect(result.get('f2')).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('author path', () => {
|
||
|
|
it('should grant access to attached files for the agent author', async () => {
|
||
|
|
getAgent.mockResolvedValue(makeAgent());
|
||
|
|
|
||
|
|
const result = await hasAccessToFilesViaAgent({
|
||
|
|
userId: AUTHOR_ID,
|
||
|
|
fileIds: ['attached-1', 'not-attached'],
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.get('attached-1')).toBe(true);
|
||
|
|
expect(result.get('not-attached')).toBe(false);
|
||
|
|
expect(checkPermission).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('VIEW permission path', () => {
|
||
|
|
it('should grant access to attached files for viewer with VIEW permission', async () => {
|
||
|
|
getAgent.mockResolvedValue(makeAgent());
|
||
|
|
checkPermission.mockResolvedValue(true);
|
||
|
|
|
||
|
|
const result = await hasAccessToFilesViaAgent({
|
||
|
|
userId: USER_ID,
|
||
|
|
role: 'USER',
|
||
|
|
fileIds: ['attached-1', 'attached-3', 'not-attached'],
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.get('attached-1')).toBe(true);
|
||
|
|
expect(result.get('attached-3')).toBe(true);
|
||
|
|
expect(result.get('not-attached')).toBe(false);
|
||
|
|
|
||
|
|
expect(checkPermission).toHaveBeenCalledWith({
|
||
|
|
userId: USER_ID,
|
||
|
|
role: 'USER',
|
||
|
|
resourceType: ResourceType.AGENT,
|
||
|
|
resourceId: AGENT_MONGO_ID,
|
||
|
|
requiredPermission: PermissionBits.VIEW,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should deny all when VIEW permission is missing', async () => {
|
||
|
|
getAgent.mockResolvedValue(makeAgent());
|
||
|
|
checkPermission.mockResolvedValue(false);
|
||
|
|
|
||
|
|
const result = await hasAccessToFilesViaAgent({
|
||
|
|
userId: USER_ID,
|
||
|
|
fileIds: ['attached-1'],
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.get('attached-1')).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('delete path (EDIT permission required)', () => {
|
||
|
|
it('should grant access when both VIEW and EDIT pass', async () => {
|
||
|
|
getAgent.mockResolvedValue(makeAgent());
|
||
|
|
checkPermission.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
|
||
|
|
|
||
|
|
const result = await hasAccessToFilesViaAgent({
|
||
|
|
userId: USER_ID,
|
||
|
|
fileIds: ['attached-1'],
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
isDelete: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.get('attached-1')).toBe(true);
|
||
|
|
expect(checkPermission).toHaveBeenCalledTimes(2);
|
||
|
|
expect(checkPermission).toHaveBeenLastCalledWith(
|
||
|
|
expect.objectContaining({ requiredPermission: PermissionBits.EDIT }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should deny all when VIEW passes but EDIT fails', async () => {
|
||
|
|
getAgent.mockResolvedValue(makeAgent());
|
||
|
|
checkPermission.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||
|
|
|
||
|
|
const result = await hasAccessToFilesViaAgent({
|
||
|
|
userId: USER_ID,
|
||
|
|
fileIds: ['attached-1'],
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
isDelete: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.get('attached-1')).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('error handling', () => {
|
||
|
|
it('should return all-false map on DB error (fail-closed)', async () => {
|
||
|
|
getAgent.mockRejectedValue(new Error('connection refused'));
|
||
|
|
|
||
|
|
const result = await hasAccessToFilesViaAgent({
|
||
|
|
userId: USER_ID,
|
||
|
|
fileIds: ['f1', 'f2'],
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.get('f1')).toBe(false);
|
||
|
|
expect(result.get('f2')).toBe(false);
|
||
|
|
expect(logger.error).toHaveBeenCalledWith(
|
||
|
|
'[hasAccessToFilesViaAgent] Error checking file access:',
|
||
|
|
expect.any(Error),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('agent with no tool_resources', () => {
|
||
|
|
it('should deny all files even for the author', async () => {
|
||
|
|
getAgent.mockResolvedValue(makeAgent({ tool_resources: undefined }));
|
||
|
|
|
||
|
|
const result = await hasAccessToFilesViaAgent({
|
||
|
|
userId: AUTHOR_ID,
|
||
|
|
fileIds: ['f1'],
|
||
|
|
agentId: AGENT_ID,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.get('f1')).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|