mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 20:32:58 +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
* feat: support data retention for normal chats Add retentionMode config variable supporting "all" and "temporary" values. When "all" is set, data retention applies to all chats, not just temporary ones. Adds isTemporary field to conversations for proper filtering. Adapted to new TS method files in packages/data-schemas since upstream moved models out of api/models/. Based on danny-avila/LibreChat#10532 Co-Authored-By: WhammyLeaf <233105313+WhammyLeaf@users.noreply.github.com> (cherry picked from commit30109e90b0) * feat: extend data retention to files, tool calls, and shared links Add expiredAt field and TTL indexes to file, toolCall, and share schemas. Set expiredAt on tool calls, shared links, and file uploads when retentionMode is "all" or chat is temporary. (cherry picked from commit48973752d3) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: lint/test (cherry picked from commit310c514e6a) * fix: address code review feedback for data retention PR Critical: - Fix BookmarkMenu crash: restore optional chaining on conversation - Fix migration hazard: backward-compatible sidebar filter that also checks expiredAt for documents without isTemporary field Major: - Add logging to getRetentionExpiry error path, align with tools.js - Add tests for retentionMode: ALL in saveConvo and saveMessage - Fix share route: apply expiredAt for temporary chats too by querying the conversation's isTemporary flag server-side - Add assertions for getRetentionExpiry mocks in process tests Minor: - Fix ChatRoute isTemporaryChat to be strictly boolean via Boolean() - Fix stale test description (expired -> temporary) - Comment out retentionMode default in example yaml - Simplify verbose if/else to isTemporary === true - Add compound index on { user: 1, isTemporary: 1 } - Remove narrating comment from process.spec.js Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> (cherry picked from commit6bad535f90) * chore: fix typescript (cherry picked from commit826527a46b) * fix: lint (cherry picked from commit77817e80ea) * fix: use mockSanitizeArtifactPath in retention test The 'getRetentionExpiry is called with the request object' test referenced an undefined `mockSanitizeFilename` identifier, breaking both lint (no-undef) and the test suite. Use the existing `mockSanitizeArtifactPath` mock that the surrounding tests already use, since `processCodeOutput` calls `sanitizeArtifactPath` (not `sanitizeFilename`) before invoking `getRetentionExpiry`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit52ea2da66d) * fix: forward isTemporary from client for retention on file uploads and tool calls Server-side `getRetentionExpiry` (file uploads) and the tool-call controller both read `req.body.isTemporary`, but the file upload multipart form and the tool-call payload did not include that field. In `retentionMode: temporary` (default), files uploaded and tool calls created from temporary chats were therefore retained indefinitely. Forward the Recoil `isTemporary` flag in both client paths so the existing server checks can fire correctly. `ToolParams` gains an optional `isTemporary` field. Addresses Codex P1 review feedback on PR #29. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit7e937df05a) * test: stub store.isTemporary in useFileHandling test mocks Previous commit added `useRecoilValue(store.isTemporary)` to the hook. The test file mocks `~/store` with only `ephemeralAgentByConvoId` and does not stub `useRecoilValue`, so all 7 cases threw "Invalid argument to useRecoilValue: expected an atom or selector but got undefined". Add a stub default export with `isTemporary` and a `useRecoilValue` mock returning `false`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commiteb1609537d) * fix: harden data retention semantics * fix: provide sweep request context for expired files * fix: preserve temporary flags in all-retention updates * fix: honor assistant versions in retention sweeps * fix: retain non-temporary flags in all mode * fix: hide expired retained records * fix: propagate retained conversation expiry * fix: refresh meili retention cutoff * fix: prevent overlapping file sweeps * fix: show legacy retained conversations * fix: index legacy retained records * fix: harden retention cleanup edge cases * fix: count failed file storage sweeps * fix: preserve legacy temporary retention * fix: assign retention sweep worker deterministically * fix: hide expired shared links on reads * fix: prevent retention refresh after parent expiry * fix: break code output retention import cycle * fix: harden retention review findings * fix: ignore expired share duplicates * fix: reject expired retained share creation * fix: harden retention review edge cases * fix: address retention audit findings * fix: enforce expired conversation shares in all retention * fix: scope temporary upload flag to chat files * fix: address retention review findings * fix: address codex retention review findings * fix: tighten missing storage detection * test: remove unused file process spec bindings --------- Co-authored-by: WhammyLeaf <233105313+WhammyLeaf@users.noreply.github.com> Co-authored-by: Aron Gates <aron@muonspace.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
739 lines
22 KiB
JavaScript
739 lines
22 KiB
JavaScript
const express = require('express');
|
|
const request = require('supertest');
|
|
const mongoose = require('mongoose');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
|
const { createMethods, SystemCapabilities } = require('@librechat/data-schemas');
|
|
const {
|
|
SystemRoles,
|
|
AccessRoleIds,
|
|
ResourceType,
|
|
PrincipalType,
|
|
} = require('librechat-data-provider');
|
|
const { createAgent, createFile } = require('~/models');
|
|
|
|
// Only mock the external dependencies that we don't want to test
|
|
jest.mock('~/server/services/Files/process', () => ({
|
|
processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
|
|
filterFile: jest.fn(),
|
|
processFileUpload: jest.fn(),
|
|
processAgentFileUpload: jest.fn().mockImplementation(async ({ res }) => {
|
|
// processAgentFileUpload sends response directly via res.json()
|
|
return res.status(200).json({
|
|
message: 'Agent file uploaded and processed successfully',
|
|
file_id: 'test-file-id',
|
|
});
|
|
}),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/strategies', () => ({
|
|
getStrategyFunctions: jest.fn(() => ({})),
|
|
}));
|
|
|
|
jest.mock('~/server/controllers/assistants/helpers', () => ({
|
|
getOpenAIClient: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
|
loadAuthValues: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('sharp', () =>
|
|
jest.fn(() => ({
|
|
metadata: jest.fn().mockResolvedValue({}),
|
|
toFormat: jest.fn().mockReturnThis(),
|
|
toBuffer: jest.fn().mockResolvedValue(Buffer.alloc(0)),
|
|
})),
|
|
);
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
...jest.requireActual('@librechat/api'),
|
|
refreshS3FileUrls: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/cache', () => ({
|
|
getLogStores: jest.fn(() => ({
|
|
get: jest.fn(),
|
|
set: jest.fn(),
|
|
})),
|
|
}));
|
|
|
|
// Mock fs.promises.unlink to prevent file cleanup errors in tests
|
|
jest.mock('fs', () => {
|
|
const actualFs = jest.requireActual('fs');
|
|
return {
|
|
...actualFs,
|
|
promises: {
|
|
...actualFs.promises,
|
|
unlink: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
};
|
|
});
|
|
|
|
const { processAgentFileUpload } = require('~/server/services/Files/process');
|
|
|
|
// Import the router
|
|
const router = require('~/server/routes/files/files');
|
|
|
|
describe('File Routes - Agent Files Endpoint', () => {
|
|
let app;
|
|
let mongoServer;
|
|
let authorId;
|
|
let otherUserId;
|
|
let agentId;
|
|
let fileId1;
|
|
let fileId2;
|
|
let fileId3;
|
|
let File;
|
|
let User;
|
|
let Agent;
|
|
let methods;
|
|
let AclEntry;
|
|
// eslint-disable-next-line no-unused-vars
|
|
let AccessRole;
|
|
let SystemGrant;
|
|
let modelsToCleanup = [];
|
|
|
|
beforeAll(async () => {
|
|
mongoServer = await MongoMemoryServer.create();
|
|
const mongoUri = mongoServer.getUri();
|
|
await mongoose.connect(mongoUri);
|
|
|
|
// Initialize all models using createModels
|
|
const { createModels } = require('@librechat/data-schemas');
|
|
const models = createModels(mongoose);
|
|
|
|
// Track which models we're adding
|
|
modelsToCleanup = Object.keys(models);
|
|
|
|
// Register models on mongoose.models so methods can access them
|
|
Object.assign(mongoose.models, models);
|
|
|
|
// Create methods with our test mongoose instance
|
|
methods = createMethods(mongoose);
|
|
|
|
// Now we can access models from the db/models
|
|
File = models.File;
|
|
Agent = models.Agent;
|
|
AclEntry = models.AclEntry;
|
|
User = models.User;
|
|
AccessRole = models.AccessRole;
|
|
SystemGrant = models.SystemGrant;
|
|
|
|
// Seed default roles using our methods
|
|
await methods.seedDefaultRoles();
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
|
|
// Mock authentication middleware
|
|
app.use((req, res, next) => {
|
|
req.user = { id: otherUserId || 'default-user' };
|
|
req.app = { locals: {} };
|
|
next();
|
|
});
|
|
|
|
app.use('/files', router);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Clean up all collections before disconnecting
|
|
const collections = mongoose.connection.collections;
|
|
for (const key in collections) {
|
|
await collections[key].deleteMany({});
|
|
}
|
|
|
|
// Clear only the models we added
|
|
for (const modelName of modelsToCleanup) {
|
|
if (mongoose.models[modelName]) {
|
|
delete mongoose.models[modelName];
|
|
}
|
|
}
|
|
|
|
await mongoose.disconnect();
|
|
await mongoServer.stop();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clean up all test data
|
|
await File.deleteMany({});
|
|
await Agent.deleteMany({});
|
|
await User.deleteMany({});
|
|
await AclEntry.deleteMany({});
|
|
// Don't delete AccessRole as they are seeded defaults needed for tests
|
|
|
|
// Create test users
|
|
authorId = new mongoose.Types.ObjectId();
|
|
otherUserId = new mongoose.Types.ObjectId();
|
|
agentId = uuidv4();
|
|
fileId1 = uuidv4();
|
|
fileId2 = uuidv4();
|
|
fileId3 = uuidv4();
|
|
|
|
// Create users in database
|
|
await User.create({
|
|
_id: authorId,
|
|
username: 'author',
|
|
email: 'author@test.com',
|
|
});
|
|
|
|
await User.create({
|
|
_id: otherUserId,
|
|
username: 'other',
|
|
email: 'other@test.com',
|
|
});
|
|
|
|
// Create files
|
|
await createFile({
|
|
user: authorId,
|
|
file_id: fileId1,
|
|
filename: 'file1.txt',
|
|
filepath: '/uploads/file1.txt',
|
|
bytes: 100,
|
|
type: 'text/plain',
|
|
});
|
|
|
|
await createFile({
|
|
user: authorId,
|
|
file_id: fileId2,
|
|
filename: 'file2.txt',
|
|
filepath: '/uploads/file2.txt',
|
|
bytes: 200,
|
|
type: 'text/plain',
|
|
});
|
|
|
|
await createFile({
|
|
user: otherUserId,
|
|
file_id: fileId3,
|
|
filename: 'file3.txt',
|
|
filepath: '/uploads/file3.txt',
|
|
bytes: 300,
|
|
type: 'text/plain',
|
|
});
|
|
});
|
|
|
|
describe('GET /files/agent/:agent_id', () => {
|
|
it('should return files accessible through the agent for non-author with EDIT permission', async () => {
|
|
// Create an agent with files attached
|
|
const agent = await createAgent({
|
|
id: agentId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
tool_resources: {
|
|
file_search: {
|
|
file_ids: [fileId1, fileId2],
|
|
},
|
|
},
|
|
});
|
|
|
|
// Grant EDIT permission to user on the agent using PermissionService
|
|
const { grantPermission } = require('~/server/services/PermissionService');
|
|
await grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: otherUserId,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
accessRoleId: AccessRoleIds.AGENT_EDITOR,
|
|
grantedBy: authorId,
|
|
});
|
|
|
|
// Mock req.user for this request
|
|
app.use((req, res, next) => {
|
|
req.user = { id: otherUserId.toString() };
|
|
next();
|
|
});
|
|
|
|
const response = await request(app).get(`/files/agent/${agentId}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
expect(response.body).toHaveLength(2);
|
|
expect(response.body.map((f) => f.file_id)).toContain(fileId1);
|
|
expect(response.body.map((f) => f.file_id)).toContain(fileId2);
|
|
});
|
|
|
|
it('should return 400 when agent_id is not provided', async () => {
|
|
const response = await request(app).get('/files/agent/');
|
|
|
|
expect(response.status).toBe(404); // Express returns 404 for missing route parameter
|
|
});
|
|
|
|
it('should return empty array for non-existent agent', async () => {
|
|
const response = await request(app).get('/files/agent/non-existent-agent');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
expect(response.body).toEqual([]);
|
|
});
|
|
|
|
it('should return empty array when user only has VIEW permission', async () => {
|
|
// Create an agent with files attached
|
|
const agent = await createAgent({
|
|
id: agentId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
tool_resources: {
|
|
file_search: {
|
|
file_ids: [fileId1, fileId2],
|
|
},
|
|
},
|
|
});
|
|
|
|
// Grant only VIEW permission to user on the agent
|
|
const { grantPermission } = require('~/server/services/PermissionService');
|
|
await grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: otherUserId,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
|
grantedBy: authorId,
|
|
});
|
|
|
|
const response = await request(app).get(`/files/agent/${agentId}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
expect(response.body).toEqual([]);
|
|
});
|
|
|
|
it('should return agent files for agent author', async () => {
|
|
// Create an agent with files attached
|
|
await createAgent({
|
|
id: agentId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
tool_resources: {
|
|
file_search: {
|
|
file_ids: [fileId1, fileId2],
|
|
},
|
|
},
|
|
});
|
|
|
|
// Create a new app instance with author authentication
|
|
const authorApp = express();
|
|
authorApp.use(express.json());
|
|
authorApp.use((req, res, next) => {
|
|
req.user = { id: authorId.toString() };
|
|
req.app = { locals: {} };
|
|
next();
|
|
});
|
|
authorApp.use('/files', router);
|
|
|
|
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
expect(response.body).toHaveLength(2);
|
|
});
|
|
|
|
it('should not return files owned by other users through agent file references', async () => {
|
|
const anotherUserId = new mongoose.Types.ObjectId();
|
|
const otherUserFileId = uuidv4();
|
|
|
|
await User.create({
|
|
_id: anotherUserId,
|
|
username: 'another',
|
|
email: 'another@test.com',
|
|
});
|
|
|
|
await createFile({
|
|
user: anotherUserId,
|
|
file_id: otherUserFileId,
|
|
filename: 'other-user-file.txt',
|
|
filepath: '/uploads/other-user-file.txt',
|
|
bytes: 400,
|
|
type: 'text/plain',
|
|
});
|
|
|
|
// Create agent to include the file uploaded by another user
|
|
await createAgent({
|
|
id: agentId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
tool_resources: {
|
|
file_search: {
|
|
file_ids: [fileId1, otherUserFileId],
|
|
},
|
|
},
|
|
});
|
|
|
|
// Create a new app instance with author authentication
|
|
const authorApp = express();
|
|
authorApp.use(express.json());
|
|
authorApp.use((req, res, next) => {
|
|
req.user = { id: authorId.toString() };
|
|
req.app = { locals: {} };
|
|
next();
|
|
});
|
|
authorApp.use('/files', router);
|
|
|
|
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
expect(response.body).toHaveLength(1);
|
|
expect(response.body.map((f) => f.file_id)).toContain(fileId1);
|
|
expect(response.body.map((f) => f.file_id)).not.toContain(otherUserFileId);
|
|
});
|
|
});
|
|
|
|
describe('POST /files - Agent File Upload Permission Check', () => {
|
|
let agentCustomId;
|
|
|
|
beforeEach(async () => {
|
|
agentCustomId = `agent_${uuidv4().replace(/-/g, '').substring(0, 21)}`;
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
/**
|
|
* Helper to create an Express app with specific user context
|
|
*/
|
|
const createAppWithUser = (userId, userRole = SystemRoles.USER) => {
|
|
const testApp = express();
|
|
testApp.use(express.json());
|
|
|
|
// Mock multer - populate req.file
|
|
testApp.use((req, res, next) => {
|
|
if (req.method === 'POST') {
|
|
req.file = {
|
|
originalname: 'test.txt',
|
|
mimetype: 'text/plain',
|
|
size: 100,
|
|
path: '/tmp/test.txt',
|
|
};
|
|
req.file_id = uuidv4();
|
|
}
|
|
next();
|
|
});
|
|
|
|
testApp.use((req, res, next) => {
|
|
req.user = { id: userId.toString(), role: userRole };
|
|
req.app = { locals: {} };
|
|
req.config = { fileStrategy: 'local' };
|
|
next();
|
|
});
|
|
|
|
testApp.use('/files', router);
|
|
return testApp;
|
|
};
|
|
|
|
it('should deny file upload to agent when user has no permission', async () => {
|
|
// Create an agent owned by authorId
|
|
await createAgent({
|
|
id: agentCustomId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
});
|
|
|
|
const testApp = createAppWithUser(otherUserId);
|
|
|
|
const response = await request(testApp).post('/files').send({
|
|
endpoint: 'agents',
|
|
agent_id: agentCustomId,
|
|
tool_resource: 'context',
|
|
file_id: uuidv4(),
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body.error).toBe('Forbidden');
|
|
expect(response.body.message).toBe('Insufficient permissions to upload files to this agent');
|
|
expect(processAgentFileUpload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow file upload to agent for agent author', async () => {
|
|
// Create an agent owned by authorId
|
|
await createAgent({
|
|
id: agentCustomId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
});
|
|
|
|
const testApp = createAppWithUser(authorId);
|
|
|
|
const response = await request(testApp).post('/files').send({
|
|
endpoint: 'agents',
|
|
agent_id: agentCustomId,
|
|
tool_resource: 'context',
|
|
file_id: uuidv4(),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow file upload to agent for user with EDIT permission', async () => {
|
|
// Create an agent owned by authorId
|
|
const agent = await createAgent({
|
|
id: agentCustomId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
});
|
|
|
|
// Grant EDIT permission to otherUserId
|
|
const { grantPermission } = require('~/server/services/PermissionService');
|
|
await grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: otherUserId,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
accessRoleId: AccessRoleIds.AGENT_EDITOR,
|
|
grantedBy: authorId,
|
|
});
|
|
|
|
const testApp = createAppWithUser(otherUserId);
|
|
|
|
const response = await request(testApp).post('/files').send({
|
|
endpoint: 'agents',
|
|
agent_id: agentCustomId,
|
|
tool_resource: 'context',
|
|
file_id: uuidv4(),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should deny file upload to agent for user with only VIEW permission', async () => {
|
|
// Create an agent owned by authorId
|
|
const agent = await createAgent({
|
|
id: agentCustomId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
});
|
|
|
|
// Grant only VIEW permission to otherUserId
|
|
const { grantPermission } = require('~/server/services/PermissionService');
|
|
await grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: otherUserId,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
|
grantedBy: authorId,
|
|
});
|
|
|
|
const testApp = createAppWithUser(otherUserId);
|
|
|
|
const response = await request(testApp).post('/files').send({
|
|
endpoint: 'agents',
|
|
agent_id: agentCustomId,
|
|
tool_resource: 'file_search',
|
|
file_id: uuidv4(),
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body.error).toBe('Forbidden');
|
|
expect(processAgentFileUpload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow file upload for user with MANAGE_AGENTS capability regardless of agent ownership', async () => {
|
|
// Create an agent owned by authorId
|
|
await createAgent({
|
|
id: agentCustomId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
});
|
|
|
|
// Seed MANAGE_AGENTS capability for the ADMIN role
|
|
await SystemGrant.create({
|
|
principalType: PrincipalType.ROLE,
|
|
principalId: SystemRoles.ADMIN,
|
|
capability: SystemCapabilities.MANAGE_AGENTS,
|
|
grantedAt: new Date(),
|
|
});
|
|
|
|
// Create app with admin user (otherUserId as admin)
|
|
const testApp = createAppWithUser(otherUserId, SystemRoles.ADMIN);
|
|
|
|
const response = await request(testApp).post('/files').send({
|
|
endpoint: 'agents',
|
|
agent_id: agentCustomId,
|
|
tool_resource: 'context',
|
|
file_id: uuidv4(),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 404 when uploading to non-existent agent', async () => {
|
|
const testApp = createAppWithUser(otherUserId);
|
|
|
|
const response = await request(testApp).post('/files').send({
|
|
endpoint: 'agents',
|
|
agent_id: 'agent_nonexistent123456789',
|
|
tool_resource: 'context',
|
|
file_id: uuidv4(),
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.error).toBe('Not Found');
|
|
expect(response.body.message).toBe('Agent not found');
|
|
expect(processAgentFileUpload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow file upload without agent_id (message attachment)', async () => {
|
|
const testApp = createAppWithUser(otherUserId);
|
|
|
|
const response = await request(testApp).post('/files').send({
|
|
endpoint: 'agents',
|
|
file_id: uuidv4(),
|
|
// No agent_id or tool_resource - this is a message attachment
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow file upload with agent_id but no tool_resource (message attachment)', async () => {
|
|
// Create an agent owned by authorId
|
|
await createAgent({
|
|
id: agentCustomId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
});
|
|
|
|
const testApp = createAppWithUser(otherUserId);
|
|
|
|
const response = await request(testApp).post('/files').send({
|
|
endpoint: 'agents',
|
|
agent_id: agentCustomId,
|
|
file_id: uuidv4(),
|
|
// No tool_resource - permission check should not apply
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow message_file attachment to agent even without EDIT permission', async () => {
|
|
// Create an agent owned by authorId
|
|
const agent = await createAgent({
|
|
id: agentCustomId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
});
|
|
|
|
// Grant only VIEW permission to otherUserId
|
|
const { grantPermission } = require('~/server/services/PermissionService');
|
|
await grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: otherUserId,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
|
grantedBy: authorId,
|
|
});
|
|
|
|
const testApp = createAppWithUser(otherUserId);
|
|
|
|
// message_file: true indicates this is a chat message attachment, not a permanent file upload
|
|
const response = await request(testApp).post('/files').send({
|
|
endpoint: 'agents',
|
|
agent_id: agentCustomId,
|
|
tool_resource: 'context',
|
|
message_file: true,
|
|
file_id: uuidv4(),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow message_file attachment (string "true") to agent even without EDIT permission', async () => {
|
|
// Create an agent owned by authorId
|
|
const agent = await createAgent({
|
|
id: agentCustomId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
});
|
|
|
|
// Grant only VIEW permission to otherUserId
|
|
const { grantPermission } = require('~/server/services/PermissionService');
|
|
await grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: otherUserId,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
|
grantedBy: authorId,
|
|
});
|
|
|
|
const testApp = createAppWithUser(otherUserId);
|
|
|
|
// message_file as string "true" (from form data) should also be allowed
|
|
const response = await request(testApp).post('/files').send({
|
|
endpoint: 'agents',
|
|
agent_id: agentCustomId,
|
|
tool_resource: 'context',
|
|
message_file: 'true',
|
|
file_id: uuidv4(),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should deny file upload when message_file is false (not a message attachment)', async () => {
|
|
// Create an agent owned by authorId
|
|
const agent = await createAgent({
|
|
id: agentCustomId,
|
|
name: 'Test Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: authorId,
|
|
});
|
|
|
|
// Grant only VIEW permission to otherUserId
|
|
const { grantPermission } = require('~/server/services/PermissionService');
|
|
await grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: otherUserId,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
|
grantedBy: authorId,
|
|
});
|
|
|
|
const testApp = createAppWithUser(otherUserId);
|
|
|
|
// message_file: false should NOT bypass permission check
|
|
const response = await request(testApp).post('/files').send({
|
|
endpoint: 'agents',
|
|
agent_id: agentCustomId,
|
|
tool_resource: 'context',
|
|
message_file: false,
|
|
file_id: uuidv4(),
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body.error).toBe('Forbidden');
|
|
expect(processAgentFileUpload).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|