2025-07-05 11:34:28 -04:00
|
|
|
const mongoose = require('mongoose');
|
2025-08-05 19:25:03 -04:00
|
|
|
const { nanoid } = require('nanoid');
|
2026-01-14 04:01:11 +10:00
|
|
|
const { v4: uuidv4 } = require('uuid');
|
2025-07-05 11:34:28 -04:00
|
|
|
const { agentSchema } = require('@librechat/data-schemas');
|
🪪 fix: Enforce VIEW ACL on Agent Edge References at Write and Runtime (#12246)
* 🛡️ fix: Enforce ACL checks on handoff edge and added-convo agent loading
Edge-linked agents and added-convo agents were fetched by ID via
getAgent without verifying the requesting user's access permissions.
This allowed an authenticated user to reference another user's private
agent in edges or addedConvo and have it initialized at runtime.
Add checkPermission(VIEW) gate in processAgent before initializing
any handoff agent, and in processAddedConvo for non-ephemeral added
agents. Unauthorized agents are logged and added to skippedAgentIds
so orphaned-edge filtering removes them cleanly.
* 🛡️ fix: Validate edge agent access at agent create/update time
Reject agent create/update requests that reference agents in edges
the requesting user cannot VIEW. This provides early feedback and
prevents storing unauthorized agent references as defense-in-depth
alongside the runtime ACL gate in processAgent.
Add collectEdgeAgentIds utility to extract all unique agent IDs from
an edge array, and validateEdgeAgentAccess helper in the v1 handler.
* 🧪 test: Improve ACL gate test coverage and correctness
- Add processAgent ACL gate tests for initializeClient (skip/allow handoff agents)
- Fix addedConvo.spec.js to mock loadAddedAgent directly instead of getAgent
- Seed permMap with ownedAgent VIEW bits in v1.spec.js update-403 test
* 🧹 chore: Remove redundant addedConvo ACL gate (now in middleware)
PR #12243 moved the addedConvo agent ACL check upstream into
canAccessAgentFromBody middleware, making the runtime check in
processAddedConvo and its spec redundant.
* 🧪 test: Rewrite processAgent ACL test with real DB and minimal mocking
Replace heavy mock-based test (12 mocks, Providers.XAI crash) with
MongoMemoryServer-backed integration test that exercises real getAgent,
checkPermission, and AclEntry — only external I/O (initializeAgent,
ToolService, AgentClient) remains mocked. Load edge utilities directly
from packages/api/src/agents/edges to sidestep the config.ts barrel.
* 🧪 fix: Use requireActual spread for @librechat/agents and @librechat/api mocks
The Providers.XAI crash was caused by mocking @librechat/agents with
a minimal replacement object, breaking the @librechat/api initialization
chain. Match the established pattern from client.test.js and
recordCollectedUsage.spec.js: spread jest.requireActual for both
packages, overriding only the functions under test.
2026-03-15 18:08:57 -04:00
|
|
|
const { FileSources, PermissionBits } = require('librechat-data-provider');
|
2026-01-14 04:01:11 +10:00
|
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
2025-07-05 11:34:28 -04:00
|
|
|
|
|
|
|
|
// Only mock the dependencies that are not database-related
|
|
|
|
|
jest.mock('~/server/services/Config', () => ({
|
|
|
|
|
getCachedTools: jest.fn().mockResolvedValue({
|
|
|
|
|
web_search: true,
|
|
|
|
|
execute_code: true,
|
|
|
|
|
file_search: true,
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/models/Project', () => ({
|
|
|
|
|
getProjectByName: jest.fn().mockResolvedValue(null),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/server/services/Files/strategies', () => ({
|
|
|
|
|
getStrategyFunctions: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/server/services/Files/images/avatar', () => ({
|
|
|
|
|
resizeAvatar: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/server/services/Files/S3/crud', () => ({
|
|
|
|
|
refreshS3Url: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/server/services/Files/process', () => ({
|
|
|
|
|
filterFile: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/models/Action', () => ({
|
|
|
|
|
updateAction: jest.fn(),
|
|
|
|
|
getActions: jest.fn().mockResolvedValue([]),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/models/File', () => ({
|
|
|
|
|
deleteFileByFilter: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2025-08-05 19:25:03 -04:00
|
|
|
jest.mock('~/server/services/PermissionService', () => ({
|
|
|
|
|
findAccessibleResources: jest.fn().mockResolvedValue([]),
|
|
|
|
|
findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
|
🪪 fix: Enforce VIEW ACL on Agent Edge References at Write and Runtime (#12246)
* 🛡️ fix: Enforce ACL checks on handoff edge and added-convo agent loading
Edge-linked agents and added-convo agents were fetched by ID via
getAgent without verifying the requesting user's access permissions.
This allowed an authenticated user to reference another user's private
agent in edges or addedConvo and have it initialized at runtime.
Add checkPermission(VIEW) gate in processAgent before initializing
any handoff agent, and in processAddedConvo for non-ephemeral added
agents. Unauthorized agents are logged and added to skippedAgentIds
so orphaned-edge filtering removes them cleanly.
* 🛡️ fix: Validate edge agent access at agent create/update time
Reject agent create/update requests that reference agents in edges
the requesting user cannot VIEW. This provides early feedback and
prevents storing unauthorized agent references as defense-in-depth
alongside the runtime ACL gate in processAgent.
Add collectEdgeAgentIds utility to extract all unique agent IDs from
an edge array, and validateEdgeAgentAccess helper in the v1 handler.
* 🧪 test: Improve ACL gate test coverage and correctness
- Add processAgent ACL gate tests for initializeClient (skip/allow handoff agents)
- Fix addedConvo.spec.js to mock loadAddedAgent directly instead of getAgent
- Seed permMap with ownedAgent VIEW bits in v1.spec.js update-403 test
* 🧹 chore: Remove redundant addedConvo ACL gate (now in middleware)
PR #12243 moved the addedConvo agent ACL check upstream into
canAccessAgentFromBody middleware, making the runtime check in
processAddedConvo and its spec redundant.
* 🧪 test: Rewrite processAgent ACL test with real DB and minimal mocking
Replace heavy mock-based test (12 mocks, Providers.XAI crash) with
MongoMemoryServer-backed integration test that exercises real getAgent,
checkPermission, and AclEntry — only external I/O (initializeAgent,
ToolService, AgentClient) remains mocked. Load edge utilities directly
from packages/api/src/agents/edges to sidestep the config.ts barrel.
* 🧪 fix: Use requireActual spread for @librechat/agents and @librechat/api mocks
The Providers.XAI crash was caused by mocking @librechat/agents with
a minimal replacement object, breaking the @librechat/api initialization
chain. Match the established pattern from client.test.js and
recordCollectedUsage.spec.js: spread jest.requireActual for both
packages, overriding only the functions under test.
2026-03-15 18:08:57 -04:00
|
|
|
getResourcePermissionsMap: jest.fn().mockResolvedValue(new Map()),
|
2025-08-05 19:25:03 -04:00
|
|
|
grantPermission: jest.fn(),
|
|
|
|
|
hasPublicPermission: jest.fn().mockResolvedValue(false),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/models', () => ({
|
|
|
|
|
getCategoriesWithCounts: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-01-14 04:01:11 +10:00
|
|
|
// Mock cache for S3 avatar refresh tests
|
|
|
|
|
const mockCache = {
|
|
|
|
|
get: jest.fn(),
|
|
|
|
|
set: jest.fn(),
|
2026-02-22 18:29:31 -05:00
|
|
|
delete: jest.fn(),
|
2026-01-14 04:01:11 +10:00
|
|
|
};
|
|
|
|
|
jest.mock('~/cache', () => ({
|
|
|
|
|
getLogStores: jest.fn(() => mockCache),
|
|
|
|
|
}));
|
|
|
|
|
|
2025-08-05 19:25:03 -04:00
|
|
|
const {
|
|
|
|
|
createAgent: createAgentHandler,
|
|
|
|
|
updateAgent: updateAgentHandler,
|
|
|
|
|
getListAgents: getListAgentsHandler,
|
|
|
|
|
} = require('./v1');
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
findAccessibleResources,
|
|
|
|
|
findPubliclyAccessibleResources,
|
🪪 fix: Enforce VIEW ACL on Agent Edge References at Write and Runtime (#12246)
* 🛡️ fix: Enforce ACL checks on handoff edge and added-convo agent loading
Edge-linked agents and added-convo agents were fetched by ID via
getAgent without verifying the requesting user's access permissions.
This allowed an authenticated user to reference another user's private
agent in edges or addedConvo and have it initialized at runtime.
Add checkPermission(VIEW) gate in processAgent before initializing
any handoff agent, and in processAddedConvo for non-ephemeral added
agents. Unauthorized agents are logged and added to skippedAgentIds
so orphaned-edge filtering removes them cleanly.
* 🛡️ fix: Validate edge agent access at agent create/update time
Reject agent create/update requests that reference agents in edges
the requesting user cannot VIEW. This provides early feedback and
prevents storing unauthorized agent references as defense-in-depth
alongside the runtime ACL gate in processAgent.
Add collectEdgeAgentIds utility to extract all unique agent IDs from
an edge array, and validateEdgeAgentAccess helper in the v1 handler.
* 🧪 test: Improve ACL gate test coverage and correctness
- Add processAgent ACL gate tests for initializeClient (skip/allow handoff agents)
- Fix addedConvo.spec.js to mock loadAddedAgent directly instead of getAgent
- Seed permMap with ownedAgent VIEW bits in v1.spec.js update-403 test
* 🧹 chore: Remove redundant addedConvo ACL gate (now in middleware)
PR #12243 moved the addedConvo agent ACL check upstream into
canAccessAgentFromBody middleware, making the runtime check in
processAddedConvo and its spec redundant.
* 🧪 test: Rewrite processAgent ACL test with real DB and minimal mocking
Replace heavy mock-based test (12 mocks, Providers.XAI crash) with
MongoMemoryServer-backed integration test that exercises real getAgent,
checkPermission, and AclEntry — only external I/O (initializeAgent,
ToolService, AgentClient) remains mocked. Load edge utilities directly
from packages/api/src/agents/edges to sidestep the config.ts barrel.
* 🧪 fix: Use requireActual spread for @librechat/agents and @librechat/api mocks
The Providers.XAI crash was caused by mocking @librechat/agents with
a minimal replacement object, breaking the @librechat/api initialization
chain. Match the established pattern from client.test.js and
recordCollectedUsage.spec.js: spread jest.requireActual for both
packages, overriding only the functions under test.
2026-03-15 18:08:57 -04:00
|
|
|
getResourcePermissionsMap,
|
2025-08-05 19:25:03 -04:00
|
|
|
} = require('~/server/services/PermissionService');
|
2025-07-05 11:34:28 -04:00
|
|
|
|
2026-01-14 04:01:11 +10:00
|
|
|
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
|
|
|
|
|
|
2025-07-05 11:34:28 -04:00
|
|
|
/**
|
|
|
|
|
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
|
|
|
|
|
*/
|
|
|
|
|
let Agent;
|
|
|
|
|
|
|
|
|
|
describe('Agent Controllers - Mass Assignment Protection', () => {
|
|
|
|
|
let mongoServer;
|
|
|
|
|
let mockReq;
|
|
|
|
|
let mockRes;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
mongoServer = await MongoMemoryServer.create();
|
|
|
|
|
const mongoUri = mongoServer.getUri();
|
|
|
|
|
await mongoose.connect(mongoUri);
|
|
|
|
|
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
|
|
|
|
|
}, 20000);
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await mongoose.disconnect();
|
|
|
|
|
await mongoServer.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
await Agent.deleteMany({});
|
|
|
|
|
|
|
|
|
|
// Reset all mocks
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
|
|
|
|
|
|
// Setup mock request and response objects
|
|
|
|
|
mockReq = {
|
|
|
|
|
user: {
|
|
|
|
|
id: new mongoose.Types.ObjectId().toString(),
|
|
|
|
|
role: 'USER',
|
|
|
|
|
},
|
|
|
|
|
body: {},
|
|
|
|
|
params: {},
|
2025-08-05 19:25:03 -04:00
|
|
|
query: {},
|
2025-07-05 11:34:28 -04:00
|
|
|
app: {
|
|
|
|
|
locals: {
|
|
|
|
|
fileStrategy: 'local',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('createAgentHandler', () => {
|
|
|
|
|
test('should create agent with allowed fields only', async () => {
|
|
|
|
|
const validData = {
|
|
|
|
|
name: 'Test Agent',
|
|
|
|
|
description: 'A test agent',
|
|
|
|
|
instructions: 'Be helpful',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
tools: ['web_search'],
|
|
|
|
|
model_parameters: { temperature: 0.7 },
|
|
|
|
|
tool_resources: {
|
|
|
|
|
file_search: { file_ids: ['file1', 'file2'] },
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockReq.body = validData;
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const createdAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(createdAgent.name).toBe('Test Agent');
|
|
|
|
|
expect(createdAgent.description).toBe('A test agent');
|
|
|
|
|
expect(createdAgent.provider).toBe('openai');
|
|
|
|
|
expect(createdAgent.model).toBe('gpt-4');
|
|
|
|
|
expect(createdAgent.author.toString()).toBe(mockReq.user.id);
|
|
|
|
|
expect(createdAgent.tools).toContain('web_search');
|
|
|
|
|
|
|
|
|
|
// Verify in database
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: createdAgent.id });
|
|
|
|
|
expect(agentInDb).toBeDefined();
|
|
|
|
|
expect(agentInDb.name).toBe('Test Agent');
|
|
|
|
|
expect(agentInDb.author.toString()).toBe(mockReq.user.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should reject creation with unauthorized fields (mass assignment protection)', async () => {
|
|
|
|
|
const maliciousData = {
|
|
|
|
|
// Required fields
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
name: 'Malicious Agent',
|
|
|
|
|
|
|
|
|
|
// Unauthorized fields that should be stripped
|
|
|
|
|
author: new mongoose.Types.ObjectId().toString(), // Should not be able to set author
|
|
|
|
|
authorName: 'Hacker', // Should be stripped
|
|
|
|
|
isCollaborative: true, // Should be stripped on creation
|
|
|
|
|
versions: [], // Should be stripped
|
|
|
|
|
_id: new mongoose.Types.ObjectId(), // Should be stripped
|
|
|
|
|
id: 'custom_agent_id', // Should be overridden
|
|
|
|
|
createdAt: new Date('2020-01-01'), // Should be stripped
|
|
|
|
|
updatedAt: new Date('2020-01-01'), // Should be stripped
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockReq.body = maliciousData;
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
|
|
|
|
|
const createdAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
|
|
|
|
|
// Verify unauthorized fields were not set
|
|
|
|
|
expect(createdAgent.author.toString()).toBe(mockReq.user.id); // Should be the request user, not the malicious value
|
|
|
|
|
expect(createdAgent.authorName).toBeUndefined();
|
|
|
|
|
expect(createdAgent.isCollaborative).toBeFalsy();
|
|
|
|
|
expect(createdAgent.versions).toHaveLength(1); // Should have exactly 1 version from creation
|
|
|
|
|
expect(createdAgent.id).not.toBe('custom_agent_id'); // Should have generated ID
|
|
|
|
|
expect(createdAgent.id).toMatch(/^agent_/); // Should have proper prefix
|
|
|
|
|
|
|
|
|
|
// Verify timestamps are recent (not the malicious dates)
|
|
|
|
|
const createdTime = new Date(createdAgent.createdAt).getTime();
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
expect(now - createdTime).toBeLessThan(5000); // Created within last 5 seconds
|
|
|
|
|
|
|
|
|
|
// Verify in database
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: createdAgent.id });
|
|
|
|
|
expect(agentInDb.author.toString()).toBe(mockReq.user.id);
|
|
|
|
|
expect(agentInDb.authorName).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should validate required fields', async () => {
|
|
|
|
|
const invalidData = {
|
|
|
|
|
name: 'Missing Required Fields',
|
|
|
|
|
// Missing provider and model
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockReq.body = invalidData;
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(400);
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
error: 'Invalid request data',
|
|
|
|
|
details: expect.any(Array),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Verify nothing was created in database
|
|
|
|
|
const count = await Agent.countDocuments();
|
|
|
|
|
expect(count).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should handle tool_resources validation', async () => {
|
|
|
|
|
const dataWithInvalidToolResources = {
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
name: 'Agent with Tool Resources',
|
|
|
|
|
tool_resources: {
|
|
|
|
|
// Valid resources
|
|
|
|
|
file_search: {
|
|
|
|
|
file_ids: ['file1', 'file2'],
|
|
|
|
|
vector_store_ids: ['vs1'],
|
|
|
|
|
},
|
|
|
|
|
execute_code: {
|
|
|
|
|
file_ids: ['file3'],
|
|
|
|
|
},
|
|
|
|
|
// Invalid resource (should be stripped by schema)
|
|
|
|
|
invalid_resource: {
|
|
|
|
|
file_ids: ['file4'],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockReq.body = dataWithInvalidToolResources;
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
|
|
|
|
|
const createdAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(createdAgent.tool_resources).toBeDefined();
|
|
|
|
|
expect(createdAgent.tool_resources.file_search).toBeDefined();
|
|
|
|
|
expect(createdAgent.tool_resources.execute_code).toBeDefined();
|
|
|
|
|
expect(createdAgent.tool_resources.invalid_resource).toBeUndefined(); // Should be stripped
|
|
|
|
|
|
|
|
|
|
// Verify in database
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: createdAgent.id });
|
|
|
|
|
expect(agentInDb.tool_resources.invalid_resource).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
🏪 feat: Agent Marketplace
bugfix: Enhance Agent and AgentCategory schemas with new fields for category, support contact, and promotion status
refactored and moved agent category methods and schema to data-schema package
🔧 fix: Merge and Rebase Conflicts
- Move AgentCategory from api/models to @packages/data-schemas structure
- Add schema, types, methods, and model following codebase conventions
- Implement auto-seeding of default categories during AppService startup
- Update marketplace controller to use new data-schemas methods
- Remove old model file and standalone seed script
refactor: unify agent marketplace to single endpoint with cursor pagination
- Replace multiple marketplace routes with unified /marketplace endpoint
- Add query string controls: category, search, limit, cursor, promoted, requiredPermission
- Implement cursor-based pagination replacing page-based system
- Integrate ACL permissions for proper access control
- Fix ObjectId constructor error in Agent model
- Update React components to use unified useGetMarketplaceAgentsQuery hook
- Enhance type safety and remove deprecated useDynamicAgentQuery
- Update tests for new marketplace architecture
-Known issues:
see more button after category switching + Unit tests
feat: add icon property to ProcessedAgentCategory interface
- Add useMarketplaceAgentsInfiniteQuery and useGetAgentCategoriesQuery to client/src/data-provider/Agents/
- Replace manual pagination in AgentGrid with infinite query pattern
- Update imports to use local data provider instead of librechat-data-provider
- Add proper permission handling with PERMISSION_BITS.VIEW/EDIT constants
- Improve agent access control by adding requiredPermission validation in backend
- Remove manual cursor/state management in favor of infinite query built-ins
- Maintain existing search and category filtering functionality
refactor: consolidate agent marketplace endpoints into main agents API and improve data management consistency
- Remove dedicated marketplace controller and routes, merging functionality into main agents v1 API
- Add countPromotedAgents function to Agent model for promoted agents count
- Enhance getListAgents handler with marketplace filtering (category, search, promoted status)
- Move getAgentCategories from marketplace to v1 controller with same functionality
- Update agent mutations to invalidate marketplace queries and handle multiple permission levels
- Improve cache management by updating all agent query variants (VIEW/EDIT permissions)
- Consolidate agent data access patterns for better maintainability and consistency
- Remove duplicate marketplace route definitions and middleware
selected view only agents injected in the drop down
fix: remove minlength validation for support contact name in agent schema
feat: add validation and error messages for agent name in AgentConfig and AgentPanel
fix: update agent permission check logic in AgentPanel to simplify condition
Fix linting WIP
Fix Unit tests WIP
ESLint fixes
eslint fix
refactor: enhance isDuplicateVersion function in Agent model for improved comparison logic
- Introduced handling for undefined/null values in array and object comparisons.
- Normalized array comparisons to treat undefined/null as empty arrays.
- Added deep comparison for objects and improved handling of primitive values.
- Enhanced projectIds comparison to ensure consistent MongoDB ObjectId handling.
refactor: remove redundant properties from IAgent interface in agent schema
chore: update localization for agent detail component and clean up imports
ci: update access middleware tests
chore: remove unused PermissionTypes import from Role model
ci: update AclEntry model tests
ci: update button accessibility labels in AgentDetail tests
refactor: update exhaustive dep. lint warning
🔧 fix: Fixed agent actions access
feat: Add role-level permissions for agent sharing people picker
- Add PEOPLE_PICKER permission type with VIEW_USERS and VIEW_GROUPS permissions
- Create custom middleware for query-aware permission validation
- Implement permission-based type filtering in PeoplePicker component
- Hide people picker UI when user lacks permissions, show only public toggle
- Support granular access: users-only, groups-only, or mixed search modes
refactor: Replace marketplace interface config with permission-based system
- Add MARKETPLACE permission type to handle marketplace access control
- Update interface configuration to use role-based marketplace settings (admin/user)
- Replace direct marketplace boolean config with permission-based checks
- Modify frontend components to use marketplace permissions instead of interface config
- Update agent query hooks to use marketplace permissions for determining permission levels
- Add marketplace configuration structure similar to peoplePicker in YAML config
- Backend now sets MARKETPLACE permissions based on interface configuration
- When marketplace enabled: users get agents with EDIT permissions in dropdown lists (builder mode)
- When marketplace disabled: users get agents with VIEW permissions in dropdown lists (browse mode)
🔧 fix: Redirect to New Chat if No Marketplace Access and Required Agent Name Placeholder (#8213)
* Fix: Fix the redirect to new chat page if access to marketplace is denied
* Fixed the required agent name placeholder
---------
Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
chore: fix tests, remove unnecessary imports
refactor: Implement permission checks for file access via agents
- Updated `hasAccessToFilesViaAgent` to utilize permission checks for VIEW and EDIT access.
- Replaced project-based access validation with permission-based checks.
- Enhanced tests to cover new permission logic and ensure proper access control for files associated with agents.
- Cleaned up imports and initialized models in test files for consistency.
refactor: Enhance test setup and cleanup for file access control
- Introduced modelsToCleanup array to track models added during tests for proper cleanup.
- Updated afterAll hooks in test files to ensure all collections are cleared and only added models are deleted.
- Improved consistency in model initialization across test files.
- Added comments for clarity on cleanup processes and test data management.
chore: Update Jest configuration and test setup for improved timeout handling
- Added a global test timeout of 30 seconds in jest.config.js.
- Configured jest.setTimeout in jestSetup.js to allow individual test overrides if needed.
- Enhanced test reliability by ensuring consistent timeout settings across all tests.
refactor: Implement file access filtering based on agent permissions
- Introduced `filterFilesByAgentAccess` function to filter files based on user access through agents.
- Updated `getFiles` and `primeFiles` functions to utilize the new filtering logic.
- Moved `hasAccessToFilesViaAgent` function from the File model to permission services, adjusting imports accordingly
- Enhanced tests to ensure proper access control and filtering behavior for files associated with agents.
fix: make support_contact field a nested object rather than a sub-document
refactor: Update support_contact field initialization in agent model
- Removed handling for empty support_contact object in createAgent function.
- Changed default value of support_contact in agent schema to undefined.
test: Add comprehensive tests for support_contact field handling and versioning
refactor: remove unused avatar upload mutation field and add informational toast for success
chore: add missing SidePanelProvider for AgentMarketplace and organize imports
fix: resolve agent selection race condition in marketplace HandleStartChat
- Set agent in localStorage before newConversation to prevent useSelectorEffects from auto-selecting previous agent
fix: resolve agent dropdown showing raw ID instead of agent info from URL
- Add proactive agent fetching when agent_id is present in URL parameters
- Inject fetched agent into agents cache so dropdowns display proper name/avatar
- Use useAgentsMap dependency to ensure proper cache initialization timing
- Prevents raw agent IDs from showing in UI when visiting shared agent links
Fix: Agents endpoint renamed to "My Agent" for less confusion with the Marketplace agents.
chore: fix ESLint issues and Test Mocks
ci: update permissions structure in loadDefaultInterface tests
- Refactored permissions for MEMORY and added new permissions for MARKETPLACE and PEOPLE_PICKER.
- Ensured consistent structure for permissions across different types.
feat: support_contact validation to allow empty email strings
2025-06-11 22:55:07 +05:30
|
|
|
test('should handle support_contact with empty strings', async () => {
|
|
|
|
|
const dataWithEmptyContact = {
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
name: 'Agent with Empty Contact',
|
|
|
|
|
support_contact: {
|
|
|
|
|
name: '',
|
|
|
|
|
email: '',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockReq.body = dataWithEmptyContact;
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
|
|
|
|
|
const createdAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(createdAgent.name).toBe('Agent with Empty Contact');
|
|
|
|
|
expect(createdAgent.support_contact).toBeDefined();
|
|
|
|
|
expect(createdAgent.support_contact.name).toBe('');
|
|
|
|
|
expect(createdAgent.support_contact.email).toBe('');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should handle support_contact with valid email', async () => {
|
|
|
|
|
const dataWithValidContact = {
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
name: 'Agent with Valid Contact',
|
|
|
|
|
support_contact: {
|
|
|
|
|
name: 'Support Team',
|
|
|
|
|
email: 'support@example.com',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockReq.body = dataWithValidContact;
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
|
|
|
|
|
const createdAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(createdAgent.support_contact).toBeDefined();
|
|
|
|
|
expect(createdAgent.support_contact.name).toBe('Support Team');
|
|
|
|
|
expect(createdAgent.support_contact.email).toBe('support@example.com');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should reject support_contact with invalid email', async () => {
|
|
|
|
|
const dataWithInvalidEmail = {
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
name: 'Agent with Invalid Email',
|
|
|
|
|
support_contact: {
|
|
|
|
|
name: 'Support',
|
|
|
|
|
email: 'not-an-email',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockReq.body = dataWithInvalidEmail;
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(400);
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
error: 'Invalid request data',
|
|
|
|
|
details: expect.arrayContaining([
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
path: ['support_contact', 'email'],
|
|
|
|
|
}),
|
|
|
|
|
]),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-05 11:34:28 -04:00
|
|
|
test('should handle avatar validation', async () => {
|
|
|
|
|
const dataWithAvatar = {
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
name: 'Agent with Avatar',
|
|
|
|
|
avatar: {
|
|
|
|
|
filepath: 'https://example.com/avatar.png',
|
|
|
|
|
source: 's3',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockReq.body = dataWithAvatar;
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
|
|
|
|
|
const createdAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(createdAgent.avatar).toEqual({
|
|
|
|
|
filepath: 'https://example.com/avatar.png',
|
|
|
|
|
source: 's3',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-07 11:26:53 -05:00
|
|
|
test('should remove empty strings from model_parameters (Issue Fix)', async () => {
|
|
|
|
|
// This tests the fix for empty strings being sent to API instead of being omitted
|
|
|
|
|
// When a user clears a numeric field (like max_tokens), it should be removed, not sent as ""
|
|
|
|
|
const dataWithEmptyModelParams = {
|
|
|
|
|
provider: 'azureOpenAI',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
name: 'Agent with Empty Model Params',
|
|
|
|
|
model_parameters: {
|
|
|
|
|
temperature: 0.7, // Valid number - should be preserved
|
|
|
|
|
max_tokens: '', // Empty string - should be removed
|
|
|
|
|
maxContextTokens: '', // Empty string - should be removed
|
|
|
|
|
topP: 0, // Zero value - should be preserved (not treated as empty)
|
|
|
|
|
frequency_penalty: '', // Empty string - should be removed
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockReq.body = dataWithEmptyModelParams;
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
|
|
|
|
|
const createdAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(createdAgent.model_parameters).toBeDefined();
|
|
|
|
|
// Valid numbers should be preserved
|
|
|
|
|
expect(createdAgent.model_parameters.temperature).toBe(0.7);
|
|
|
|
|
expect(createdAgent.model_parameters.topP).toBe(0);
|
|
|
|
|
// Empty strings should be removed
|
|
|
|
|
expect(createdAgent.model_parameters.max_tokens).toBeUndefined();
|
|
|
|
|
expect(createdAgent.model_parameters.maxContextTokens).toBeUndefined();
|
|
|
|
|
expect(createdAgent.model_parameters.frequency_penalty).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
// Verify in database
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: createdAgent.id });
|
|
|
|
|
expect(agentInDb.model_parameters.temperature).toBe(0.7);
|
|
|
|
|
expect(agentInDb.model_parameters.topP).toBe(0);
|
|
|
|
|
expect(agentInDb.model_parameters.max_tokens).toBeUndefined();
|
|
|
|
|
expect(agentInDb.model_parameters.maxContextTokens).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-05 11:34:28 -04:00
|
|
|
test('should handle invalid avatar format', async () => {
|
|
|
|
|
const dataWithInvalidAvatar = {
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
name: 'Agent with Invalid Avatar',
|
|
|
|
|
avatar: 'just-a-string', // Invalid format
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockReq.body = dataWithInvalidAvatar;
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(400);
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
error: 'Invalid request data',
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('updateAgentHandler', () => {
|
|
|
|
|
let existingAgentId;
|
|
|
|
|
let existingAgentAuthorId;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
// Create an existing agent for update tests
|
|
|
|
|
existingAgentAuthorId = new mongoose.Types.ObjectId();
|
|
|
|
|
const agent = await Agent.create({
|
|
|
|
|
id: `agent_${uuidv4()}`,
|
|
|
|
|
name: 'Original Agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-3.5-turbo',
|
|
|
|
|
author: existingAgentAuthorId,
|
|
|
|
|
description: 'Original description',
|
|
|
|
|
isCollaborative: false,
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Original Agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-3.5-turbo',
|
|
|
|
|
description: 'Original description',
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
existingAgentId = agent.id;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should update agent with allowed fields only', async () => {
|
|
|
|
|
mockReq.user.id = existingAgentAuthorId.toString(); // Set as author
|
|
|
|
|
mockReq.params.id = existingAgentId;
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
name: 'Updated Agent',
|
|
|
|
|
description: 'Updated description',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
isCollaborative: true, // This IS allowed in updates
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
|
|
|
|
expect(mockRes.status).not.toHaveBeenCalledWith(403);
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const updatedAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(updatedAgent.name).toBe('Updated Agent');
|
|
|
|
|
expect(updatedAgent.description).toBe('Updated description');
|
|
|
|
|
expect(updatedAgent.model).toBe('gpt-4');
|
|
|
|
|
expect(updatedAgent.isCollaborative).toBe(true);
|
|
|
|
|
expect(updatedAgent.author).toBe(existingAgentAuthorId.toString());
|
|
|
|
|
|
|
|
|
|
// Verify in database
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
|
|
|
|
expect(agentInDb.name).toBe('Updated Agent');
|
|
|
|
|
expect(agentInDb.isCollaborative).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should reject update with unauthorized fields (mass assignment protection)', async () => {
|
|
|
|
|
mockReq.user.id = existingAgentAuthorId.toString();
|
|
|
|
|
mockReq.params.id = existingAgentId;
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
name: 'Updated Name',
|
|
|
|
|
|
|
|
|
|
// Unauthorized fields that should be stripped
|
|
|
|
|
author: new mongoose.Types.ObjectId().toString(), // Should not be able to change author
|
|
|
|
|
authorName: 'Hacker', // Should be stripped
|
|
|
|
|
id: 'different_agent_id', // Should be stripped
|
|
|
|
|
_id: new mongoose.Types.ObjectId(), // Should be stripped
|
|
|
|
|
versions: [], // Should be stripped
|
|
|
|
|
createdAt: new Date('2020-01-01'), // Should be stripped
|
|
|
|
|
updatedAt: new Date('2020-01-01'), // Should be stripped
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const updatedAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
|
|
|
|
|
// Verify unauthorized fields were not changed
|
|
|
|
|
expect(updatedAgent.author).toBe(existingAgentAuthorId.toString()); // Should not have changed
|
|
|
|
|
expect(updatedAgent.authorName).toBeUndefined();
|
|
|
|
|
expect(updatedAgent.id).toBe(existingAgentId); // Should not have changed
|
|
|
|
|
expect(updatedAgent.name).toBe('Updated Name'); // Only this should have changed
|
|
|
|
|
|
|
|
|
|
// Verify in database
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
|
|
|
|
expect(agentInDb.author.toString()).toBe(existingAgentAuthorId.toString());
|
|
|
|
|
expect(agentInDb.id).toBe(existingAgentId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should allow admin to update any agent', async () => {
|
|
|
|
|
const adminUserId = new mongoose.Types.ObjectId().toString();
|
|
|
|
|
mockReq.user.id = adminUserId;
|
|
|
|
|
mockReq.user.role = 'ADMIN'; // Set as admin
|
|
|
|
|
mockReq.params.id = existingAgentId;
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
name: 'Admin Update',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).not.toHaveBeenCalledWith(403);
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const updatedAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(updatedAgent.name).toBe('Admin Update');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should handle projectIds updates', async () => {
|
|
|
|
|
mockReq.user.id = existingAgentAuthorId.toString();
|
|
|
|
|
mockReq.params.id = existingAgentId;
|
|
|
|
|
|
|
|
|
|
const projectId1 = new mongoose.Types.ObjectId().toString();
|
|
|
|
|
const projectId2 = new mongoose.Types.ObjectId().toString();
|
|
|
|
|
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
projectIds: [projectId1, projectId2],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const updatedAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(updatedAgent).toBeDefined();
|
|
|
|
|
// Note: updateAgentProjects requires more setup, so we just verify the handler doesn't crash
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should validate tool_resources in updates', async () => {
|
|
|
|
|
mockReq.user.id = existingAgentAuthorId.toString();
|
|
|
|
|
mockReq.params.id = existingAgentId;
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
tool_resources: {
|
2025-09-18 20:06:59 -04:00
|
|
|
/** Legacy conversion from `ocr` to `context` */
|
2025-07-05 11:34:28 -04:00
|
|
|
ocr: {
|
|
|
|
|
file_ids: ['ocr1', 'ocr2'],
|
|
|
|
|
},
|
|
|
|
|
execute_code: {
|
|
|
|
|
file_ids: ['img1'],
|
|
|
|
|
},
|
|
|
|
|
// Invalid tool resource
|
|
|
|
|
invalid_tool: {
|
|
|
|
|
file_ids: ['invalid'],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const updatedAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(updatedAgent.tool_resources).toBeDefined();
|
2025-09-18 20:06:59 -04:00
|
|
|
expect(updatedAgent.tool_resources.ocr).toBeUndefined();
|
|
|
|
|
expect(updatedAgent.tool_resources.context).toBeDefined();
|
2025-07-05 11:34:28 -04:00
|
|
|
expect(updatedAgent.tool_resources.execute_code).toBeDefined();
|
|
|
|
|
expect(updatedAgent.tool_resources.invalid_tool).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-07 11:26:53 -05:00
|
|
|
test('should remove empty strings from model_parameters during update (Issue Fix)', async () => {
|
|
|
|
|
// First create an agent with valid model_parameters
|
|
|
|
|
await Agent.updateOne(
|
|
|
|
|
{ id: existingAgentId },
|
|
|
|
|
{
|
|
|
|
|
model_parameters: {
|
|
|
|
|
temperature: 0.5,
|
|
|
|
|
max_tokens: 1000,
|
|
|
|
|
maxContextTokens: 2000,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
mockReq.user.id = existingAgentAuthorId.toString();
|
|
|
|
|
mockReq.params.id = existingAgentId;
|
|
|
|
|
// Simulate user clearing the fields (sends empty strings)
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
model_parameters: {
|
|
|
|
|
temperature: 0.7, // Change to new value
|
|
|
|
|
max_tokens: '', // Clear this field (should be removed, not sent as "")
|
|
|
|
|
maxContextTokens: '', // Clear this field (should be removed, not sent as "")
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const updatedAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(updatedAgent.model_parameters).toBeDefined();
|
|
|
|
|
// Valid number should be updated
|
|
|
|
|
expect(updatedAgent.model_parameters.temperature).toBe(0.7);
|
|
|
|
|
// Empty strings should be removed, not sent as ""
|
|
|
|
|
expect(updatedAgent.model_parameters.max_tokens).toBeUndefined();
|
|
|
|
|
expect(updatedAgent.model_parameters.maxContextTokens).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
// Verify in database
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
|
|
|
|
expect(agentInDb.model_parameters.temperature).toBe(0.7);
|
|
|
|
|
expect(agentInDb.model_parameters.max_tokens).toBeUndefined();
|
|
|
|
|
expect(agentInDb.model_parameters.maxContextTokens).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-05 11:34:28 -04:00
|
|
|
test('should return 404 for non-existent agent', async () => {
|
|
|
|
|
mockReq.user.id = existingAgentAuthorId.toString();
|
|
|
|
|
mockReq.params.id = `agent_${uuidv4()}`; // Non-existent ID
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
name: 'Update Non-existent',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(404);
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Agent not found' });
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-07 15:12:05 +09:00
|
|
|
test('should include version field in update response', async () => {
|
|
|
|
|
mockReq.user.id = existingAgentAuthorId.toString();
|
|
|
|
|
mockReq.params.id = existingAgentId;
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
name: 'Updated with Version Check',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
const updatedAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
|
|
|
|
|
// Verify version field is included and is a number
|
|
|
|
|
expect(updatedAgent).toHaveProperty('version');
|
|
|
|
|
expect(typeof updatedAgent.version).toBe('number');
|
|
|
|
|
expect(updatedAgent.version).toBeGreaterThanOrEqual(1);
|
|
|
|
|
|
|
|
|
|
// Verify in database
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
|
|
|
|
expect(updatedAgent.version).toBe(agentInDb.versions.length);
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-17 23:04:01 +01:00
|
|
|
test('should allow resetting avatar when value is explicitly null', async () => {
|
|
|
|
|
await Agent.updateOne(
|
|
|
|
|
{ id: existingAgentId },
|
|
|
|
|
{
|
|
|
|
|
avatar: {
|
|
|
|
|
filepath: 'https://example.com/avatar.png',
|
|
|
|
|
source: 's3',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
mockReq.user.id = existingAgentAuthorId.toString();
|
|
|
|
|
mockReq.params.id = existingAgentId;
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
avatar: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const updatedAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(updatedAgent.avatar).toBeNull();
|
|
|
|
|
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
|
|
|
|
expect(agentInDb.avatar).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should ignore avatar field when value is undefined', async () => {
|
|
|
|
|
const originalAvatar = {
|
|
|
|
|
filepath: 'https://example.com/original.png',
|
|
|
|
|
source: 's3',
|
|
|
|
|
};
|
|
|
|
|
await Agent.updateOne({ id: existingAgentId }, { avatar: originalAvatar });
|
|
|
|
|
|
|
|
|
|
mockReq.user.id = existingAgentAuthorId.toString();
|
|
|
|
|
mockReq.params.id = existingAgentId;
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
avatar: undefined,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
|
|
|
|
expect(agentInDb.avatar.filepath).toBe(originalAvatar.filepath);
|
|
|
|
|
expect(agentInDb.avatar.source).toBe(originalAvatar.source);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should not bump version when no mutable fields change', async () => {
|
|
|
|
|
const existingAgent = await Agent.findOne({ id: existingAgentId });
|
|
|
|
|
const originalVersionCount = existingAgent.versions.length;
|
|
|
|
|
|
|
|
|
|
mockReq.user.id = existingAgentAuthorId.toString();
|
|
|
|
|
mockReq.params.id = existingAgentId;
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
avatar: undefined,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
|
|
|
|
expect(agentInDb.versions.length).toBe(originalVersionCount);
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-05 11:34:28 -04:00
|
|
|
test('should handle validation errors properly', async () => {
|
|
|
|
|
mockReq.user.id = existingAgentAuthorId.toString();
|
|
|
|
|
mockReq.params.id = existingAgentId;
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
model_parameters: 'invalid-not-an-object', // Should be an object
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(400);
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
error: 'Invalid request data',
|
|
|
|
|
details: expect.any(Array),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Mass Assignment Attack Scenarios', () => {
|
|
|
|
|
test('should prevent setting system fields during creation', async () => {
|
|
|
|
|
const systemFields = {
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
name: 'System Fields Test',
|
|
|
|
|
|
|
|
|
|
// System fields that should never be settable by users
|
|
|
|
|
__v: 99,
|
|
|
|
|
_id: new mongoose.Types.ObjectId(),
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Fake Version',
|
|
|
|
|
provider: 'fake',
|
|
|
|
|
model: 'fake-model',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockReq.body = systemFields;
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
|
|
|
|
|
const createdAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
|
|
|
|
|
// Verify system fields were not affected
|
|
|
|
|
expect(createdAgent.__v).not.toBe(99);
|
|
|
|
|
expect(createdAgent.versions).toHaveLength(1); // Should only have the auto-created version
|
|
|
|
|
expect(createdAgent.versions[0].name).toBe('System Fields Test'); // From actual creation
|
|
|
|
|
expect(createdAgent.versions[0].provider).toBe('openai'); // From actual creation
|
|
|
|
|
|
|
|
|
|
// Verify in database
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: createdAgent.id });
|
|
|
|
|
expect(agentInDb.__v).not.toBe(99);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should prevent author hijacking', async () => {
|
|
|
|
|
const originalAuthorId = new mongoose.Types.ObjectId();
|
|
|
|
|
const attackerId = new mongoose.Types.ObjectId();
|
|
|
|
|
|
|
|
|
|
// Admin creates an agent
|
|
|
|
|
mockReq.user.id = originalAuthorId.toString();
|
|
|
|
|
mockReq.user.role = 'ADMIN';
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
name: 'Admin Agent',
|
|
|
|
|
author: attackerId.toString(), // Trying to set different author
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
|
|
|
|
|
const createdAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
|
|
|
|
|
// Author should be the actual user, not the attempted value
|
|
|
|
|
expect(createdAgent.author.toString()).toBe(originalAuthorId.toString());
|
|
|
|
|
expect(createdAgent.author.toString()).not.toBe(attackerId.toString());
|
|
|
|
|
|
|
|
|
|
// Verify in database
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: createdAgent.id });
|
|
|
|
|
expect(agentInDb.author.toString()).toBe(originalAuthorId.toString());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should strip unknown fields to prevent future vulnerabilities', async () => {
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
name: 'Future Proof Test',
|
|
|
|
|
|
|
|
|
|
// Unknown fields that might be added in future
|
|
|
|
|
superAdminAccess: true,
|
|
|
|
|
bypassAllChecks: true,
|
|
|
|
|
internalFlag: 'secret',
|
|
|
|
|
futureFeature: 'exploit',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
|
|
|
|
|
const createdAgent = mockRes.json.mock.calls[0][0];
|
|
|
|
|
|
|
|
|
|
// Verify unknown fields were stripped
|
|
|
|
|
expect(createdAgent.superAdminAccess).toBeUndefined();
|
|
|
|
|
expect(createdAgent.bypassAllChecks).toBeUndefined();
|
|
|
|
|
expect(createdAgent.internalFlag).toBeUndefined();
|
|
|
|
|
expect(createdAgent.futureFeature).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
// Also check in database
|
|
|
|
|
const agentInDb = await Agent.findOne({ id: createdAgent.id }).lean();
|
|
|
|
|
expect(agentInDb.superAdminAccess).toBeUndefined();
|
|
|
|
|
expect(agentInDb.bypassAllChecks).toBeUndefined();
|
|
|
|
|
expect(agentInDb.internalFlag).toBeUndefined();
|
|
|
|
|
expect(agentInDb.futureFeature).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
2025-08-05 19:25:03 -04:00
|
|
|
|
|
|
|
|
describe('getListAgentsHandler - Security Tests', () => {
|
|
|
|
|
let userA, userB;
|
|
|
|
|
let agentA1, agentA2, agentA3, agentB1;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
await Agent.deleteMany({});
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
|
|
|
|
|
|
// Create two test users
|
|
|
|
|
userA = new mongoose.Types.ObjectId();
|
|
|
|
|
userB = new mongoose.Types.ObjectId();
|
|
|
|
|
|
|
|
|
|
// Create agents for User A
|
|
|
|
|
agentA1 = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: 'Agent A1',
|
|
|
|
|
description: 'User A agent 1',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userA,
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Agent A1',
|
|
|
|
|
description: 'User A agent 1',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
agentA2 = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: 'Agent A2',
|
|
|
|
|
description: 'User A agent 2',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userA,
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Agent A2',
|
|
|
|
|
description: 'User A agent 2',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
agentA3 = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: 'Agent A3',
|
|
|
|
|
description: 'User A agent 3',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userA,
|
|
|
|
|
category: 'productivity',
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Agent A3',
|
|
|
|
|
description: 'User A agent 3',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
category: 'productivity',
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Create an agent for User B
|
|
|
|
|
agentB1 = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: 'Agent B1',
|
|
|
|
|
description: 'User B agent 1',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userB,
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Agent B1',
|
|
|
|
|
description: 'User B agent 1',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should return empty list when user has no accessible agents', async () => {
|
|
|
|
|
// User B has no permissions and no owned agents
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
findAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(findAccessibleResources).toHaveBeenCalledWith({
|
|
|
|
|
userId: userB.toString(),
|
|
|
|
|
role: 'USER',
|
|
|
|
|
resourceType: 'agent',
|
|
|
|
|
requiredPermissions: 1, // VIEW permission
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalledWith({
|
|
|
|
|
object: 'list',
|
|
|
|
|
data: [],
|
|
|
|
|
first_id: null,
|
|
|
|
|
last_id: null,
|
|
|
|
|
has_more: false,
|
|
|
|
|
after: null,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should not return other users agents when accessibleIds is empty', async () => {
|
|
|
|
|
// User B trying to see agents with no permissions
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
findAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.data).toHaveLength(0);
|
|
|
|
|
|
|
|
|
|
// Verify User A's agents are not included
|
|
|
|
|
const agentIds = response.data.map((a) => a.id);
|
|
|
|
|
expect(agentIds).not.toContain(agentA1.id);
|
|
|
|
|
expect(agentIds).not.toContain(agentA2.id);
|
|
|
|
|
expect(agentIds).not.toContain(agentA3.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should only return agents user has access to', async () => {
|
|
|
|
|
// User B has access to one of User A's agents
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentA1._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.data).toHaveLength(1);
|
|
|
|
|
expect(response.data[0].id).toBe(agentA1.id);
|
|
|
|
|
expect(response.data[0].name).toBe('Agent A1');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should return multiple accessible agents', async () => {
|
|
|
|
|
// User B has access to multiple agents
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentA1._id, agentA3._id, agentB1._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.data).toHaveLength(3);
|
|
|
|
|
|
|
|
|
|
const agentIds = response.data.map((a) => a.id);
|
|
|
|
|
expect(agentIds).toContain(agentA1.id);
|
|
|
|
|
expect(agentIds).toContain(agentA3.id);
|
|
|
|
|
expect(agentIds).toContain(agentB1.id);
|
|
|
|
|
expect(agentIds).not.toContain(agentA2.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should apply category filter correctly with ACL', async () => {
|
|
|
|
|
// User has access to all agents but filters by category
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
mockReq.query.category = 'productivity';
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id, agentA3._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.data).toHaveLength(1);
|
|
|
|
|
expect(response.data[0].id).toBe(agentA3.id);
|
|
|
|
|
expect(response.data[0].category).toBe('productivity');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should apply search filter correctly with ACL', async () => {
|
|
|
|
|
// User has access to multiple agents but searches for specific one
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
mockReq.query.search = 'A2';
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id, agentA3._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.data).toHaveLength(1);
|
|
|
|
|
expect(response.data[0].id).toBe(agentA2.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should handle pagination with ACL filtering', async () => {
|
|
|
|
|
// Create more agents for pagination testing
|
|
|
|
|
const moreAgents = [];
|
|
|
|
|
for (let i = 4; i <= 10; i++) {
|
|
|
|
|
const agent = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: `Agent A${i}`,
|
|
|
|
|
description: `User A agent ${i}`,
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userA,
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: `Agent A${i}`,
|
|
|
|
|
description: `User A agent ${i}`,
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
moreAgents.push(agent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// User has access to all agents
|
|
|
|
|
const allAgentIds = [agentA1, agentA2, agentA3, ...moreAgents].map((a) => a._id);
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
mockReq.query.limit = '5';
|
|
|
|
|
findAccessibleResources.mockResolvedValue(allAgentIds);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.data).toHaveLength(5);
|
|
|
|
|
expect(response.has_more).toBe(true);
|
|
|
|
|
expect(response.after).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should mark publicly accessible agents', async () => {
|
|
|
|
|
// User has access to agents, some are public
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([agentA2._id]);
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.data).toHaveLength(2);
|
|
|
|
|
|
|
|
|
|
const publicAgent = response.data.find((a) => a.id === agentA2.id);
|
|
|
|
|
const privateAgent = response.data.find((a) => a.id === agentA1.id);
|
|
|
|
|
|
|
|
|
|
expect(publicAgent.isPublic).toBe(true);
|
|
|
|
|
expect(privateAgent.isPublic).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should handle requiredPermission parameter', async () => {
|
|
|
|
|
// Test with different permission levels
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
mockReq.query.requiredPermission = '15'; // FULL_ACCESS
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentA1._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(findAccessibleResources).toHaveBeenCalledWith({
|
|
|
|
|
userId: userB.toString(),
|
|
|
|
|
role: 'USER',
|
|
|
|
|
resourceType: 'agent',
|
|
|
|
|
requiredPermissions: 15,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.data).toHaveLength(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should handle promoted filter with ACL', async () => {
|
|
|
|
|
// Create a promoted agent
|
|
|
|
|
const promotedAgent = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: 'Promoted Agent',
|
|
|
|
|
description: 'A promoted agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userA,
|
|
|
|
|
is_promoted: true,
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Promoted Agent',
|
|
|
|
|
description: 'A promoted agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
is_promoted: true,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
mockReq.query.promoted = '1';
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id, promotedAgent._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.data).toHaveLength(1);
|
|
|
|
|
expect(response.data[0].id).toBe(promotedAgent.id);
|
|
|
|
|
expect(response.data[0].is_promoted).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should handle errors gracefully', async () => {
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
findAccessibleResources.mockRejectedValue(new Error('Permission service error'));
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(500);
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalledWith({
|
|
|
|
|
error: 'Permission service error',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should respect combined filters with ACL', async () => {
|
|
|
|
|
// Create agents with specific attributes
|
|
|
|
|
const productivityPromoted = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: 'Productivity Pro',
|
|
|
|
|
description: 'A promoted productivity agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userA,
|
|
|
|
|
category: 'productivity',
|
|
|
|
|
is_promoted: true,
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Productivity Pro',
|
|
|
|
|
description: 'A promoted productivity agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
category: 'productivity',
|
|
|
|
|
is_promoted: true,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
mockReq.user.id = userB.toString();
|
|
|
|
|
mockReq.query.category = 'productivity';
|
|
|
|
|
mockReq.query.promoted = '1';
|
|
|
|
|
findAccessibleResources.mockResolvedValue([
|
|
|
|
|
agentA1._id,
|
|
|
|
|
agentA2._id,
|
|
|
|
|
agentA3._id,
|
|
|
|
|
productivityPromoted._id,
|
|
|
|
|
]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.data).toHaveLength(1);
|
|
|
|
|
expect(response.data[0].id).toBe(productivityPromoted.id);
|
|
|
|
|
expect(response.data[0].category).toBe('productivity');
|
|
|
|
|
expect(response.data[0].is_promoted).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-01-14 04:01:11 +10:00
|
|
|
|
|
|
|
|
describe('S3 Avatar Refresh', () => {
|
|
|
|
|
let userA, userB;
|
|
|
|
|
let agentWithS3Avatar, agentWithLocalAvatar, agentOwnedByOther;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
await Agent.deleteMany({});
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
|
|
|
|
|
|
// Reset cache mock
|
|
|
|
|
mockCache.get.mockResolvedValue(false);
|
|
|
|
|
mockCache.set.mockResolvedValue(undefined);
|
|
|
|
|
|
|
|
|
|
userA = new mongoose.Types.ObjectId();
|
|
|
|
|
userB = new mongoose.Types.ObjectId();
|
|
|
|
|
|
|
|
|
|
// Create agent with S3 avatar owned by userA
|
|
|
|
|
agentWithS3Avatar = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: 'Agent with S3 Avatar',
|
|
|
|
|
description: 'Has S3 avatar',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userA,
|
|
|
|
|
avatar: {
|
|
|
|
|
source: FileSources.s3,
|
|
|
|
|
filepath: 'old-s3-path.jpg',
|
|
|
|
|
},
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Agent with S3 Avatar',
|
|
|
|
|
description: 'Has S3 avatar',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Create agent with local avatar owned by userA
|
|
|
|
|
agentWithLocalAvatar = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: 'Agent with Local Avatar',
|
|
|
|
|
description: 'Has local avatar',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userA,
|
|
|
|
|
avatar: {
|
|
|
|
|
source: 'local',
|
|
|
|
|
filepath: 'local-path.jpg',
|
|
|
|
|
},
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Agent with Local Avatar',
|
|
|
|
|
description: 'Has local avatar',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Create agent with S3 avatar owned by userB
|
|
|
|
|
agentOwnedByOther = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: 'Agent Owned By Other',
|
|
|
|
|
description: 'Owned by userB',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userB,
|
|
|
|
|
avatar: {
|
|
|
|
|
source: FileSources.s3,
|
|
|
|
|
filepath: 'other-s3-path.jpg',
|
|
|
|
|
},
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Agent Owned By Other',
|
|
|
|
|
description: 'Owned by userB',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should skip avatar refresh if cache hit', async () => {
|
2026-02-22 18:29:31 -05:00
|
|
|
mockCache.get.mockResolvedValue({ urlCache: {} });
|
2026-01-14 04:01:11 +10:00
|
|
|
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
// Should not call refreshS3Url when cache hit
|
|
|
|
|
expect(refreshS3Url).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should refresh and persist S3 avatars on cache miss', async () => {
|
|
|
|
|
mockCache.get.mockResolvedValue(false);
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
refreshS3Url.mockResolvedValue('new-s3-path.jpg');
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
// Verify S3 URL was refreshed
|
|
|
|
|
expect(refreshS3Url).toHaveBeenCalled();
|
|
|
|
|
|
2026-02-22 18:29:31 -05:00
|
|
|
// Verify cache was set with urlCache map, not a plain boolean
|
|
|
|
|
expect(mockCache.set).toHaveBeenCalledWith(
|
|
|
|
|
expect.any(String),
|
|
|
|
|
expect.objectContaining({ urlCache: expect.any(Object) }),
|
|
|
|
|
expect.any(Number),
|
|
|
|
|
);
|
2026-01-14 04:01:11 +10:00
|
|
|
|
|
|
|
|
// Verify response was returned
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should refresh avatars for all accessible agents (VIEW permission)', async () => {
|
|
|
|
|
mockCache.get.mockResolvedValue(false);
|
|
|
|
|
// User A has access to both their own agent and userB's agent
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id, agentOwnedByOther._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
refreshS3Url.mockResolvedValue('new-path.jpg');
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
// Should be called for both agents - any user with VIEW access can refresh
|
|
|
|
|
expect(refreshS3Url).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should skip non-S3 avatars', async () => {
|
|
|
|
|
mockCache.get.mockResolvedValue(false);
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentWithLocalAvatar._id, agentWithS3Avatar._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
refreshS3Url.mockResolvedValue('new-path.jpg');
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
// Should only be called for S3 avatar agent
|
|
|
|
|
expect(refreshS3Url).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should not update if S3 URL unchanged', async () => {
|
|
|
|
|
mockCache.get.mockResolvedValue(false);
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
// Return the same path - no update needed
|
|
|
|
|
refreshS3Url.mockResolvedValue('old-s3-path.jpg');
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
// Verify refreshS3Url was called
|
|
|
|
|
expect(refreshS3Url).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
// Response should still be returned
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should handle S3 refresh errors gracefully', async () => {
|
|
|
|
|
mockCache.get.mockResolvedValue(false);
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
refreshS3Url.mockRejectedValue(new Error('S3 error'));
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Should not throw - handles error gracefully
|
|
|
|
|
await expect(getListAgentsHandler(mockReq, mockRes)).resolves.not.toThrow();
|
|
|
|
|
|
|
|
|
|
// Response should still be returned
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should process agents in batches', async () => {
|
|
|
|
|
mockCache.get.mockResolvedValue(false);
|
|
|
|
|
|
|
|
|
|
// Create 25 agents (should be processed in batches of 20)
|
|
|
|
|
const manyAgents = [];
|
|
|
|
|
for (let i = 0; i < 25; i++) {
|
|
|
|
|
const agent = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: `Agent ${i}`,
|
|
|
|
|
description: `Agent ${i} description`,
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userA,
|
|
|
|
|
avatar: {
|
|
|
|
|
source: FileSources.s3,
|
|
|
|
|
filepath: `path${i}.jpg`,
|
|
|
|
|
},
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: `Agent ${i}`,
|
|
|
|
|
description: `Agent ${i} description`,
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
manyAgents.push(agent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const allAgentIds = manyAgents.map((a) => a._id);
|
|
|
|
|
findAccessibleResources.mockResolvedValue(allAgentIds);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
refreshS3Url.mockImplementation((avatar) =>
|
|
|
|
|
Promise.resolve(avatar.filepath.replace('.jpg', '-new.jpg')),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
// All 25 should be processed
|
|
|
|
|
expect(refreshS3Url).toHaveBeenCalledTimes(25);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should skip agents without id or author', async () => {
|
|
|
|
|
mockCache.get.mockResolvedValue(false);
|
|
|
|
|
|
|
|
|
|
// Create agent without proper id field (edge case)
|
|
|
|
|
const agentWithoutId = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid(12)}`,
|
|
|
|
|
name: 'Agent without ID field',
|
|
|
|
|
description: 'Testing',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
author: userA,
|
|
|
|
|
avatar: {
|
|
|
|
|
source: FileSources.s3,
|
|
|
|
|
filepath: 'test-path.jpg',
|
|
|
|
|
},
|
|
|
|
|
versions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Agent without ID field',
|
|
|
|
|
description: 'Testing',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentWithoutId._id, agentWithS3Avatar._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
refreshS3Url.mockResolvedValue('new-path.jpg');
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
// Should still complete without errors
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should use MAX_AVATAR_REFRESH_AGENTS limit for full list query', async () => {
|
|
|
|
|
mockCache.get.mockResolvedValue(false);
|
|
|
|
|
findAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
// Verify that the handler completed successfully
|
|
|
|
|
expect(mockRes.json).toHaveBeenCalled();
|
|
|
|
|
});
|
2026-02-22 18:29:31 -05:00
|
|
|
|
|
|
|
|
test('should treat legacy boolean cache entry as a miss and run refresh', async () => {
|
|
|
|
|
// Simulate a cache entry written by the pre-fix code
|
|
|
|
|
mockCache.get.mockResolvedValue(true);
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
refreshS3Url.mockResolvedValue('new-s3-path.jpg');
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
// Boolean true fails the shape guard, so refresh must run
|
|
|
|
|
expect(refreshS3Url).toHaveBeenCalled();
|
|
|
|
|
// Cache is overwritten with the proper format
|
|
|
|
|
expect(mockCache.set).toHaveBeenCalledWith(
|
|
|
|
|
expect.any(String),
|
|
|
|
|
expect.objectContaining({ urlCache: expect.any(Object) }),
|
|
|
|
|
expect.any(Number),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should apply cached urlCache filepath to paginated response on cache hit', async () => {
|
|
|
|
|
const agentId = agentWithS3Avatar.id;
|
|
|
|
|
const cachedUrl = 'cached-presigned-url.jpg';
|
|
|
|
|
|
|
|
|
|
mockCache.get.mockResolvedValue({ urlCache: { [agentId]: cachedUrl } });
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(refreshS3Url).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const responseData = mockRes.json.mock.calls[0][0];
|
|
|
|
|
const agent = responseData.data.find((a) => a.id === agentId);
|
|
|
|
|
// Cached URL is served, not the stale DB value 'old-s3-path.jpg'
|
|
|
|
|
expect(agent.avatar.filepath).toBe(cachedUrl);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('should preserve DB filepath for agents absent from urlCache on cache hit', async () => {
|
|
|
|
|
mockCache.get.mockResolvedValue({ urlCache: {} });
|
|
|
|
|
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
|
|
|
|
|
findPubliclyAccessibleResources.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
const mockReq = {
|
|
|
|
|
user: { id: userA.toString(), role: 'USER' },
|
|
|
|
|
query: {},
|
|
|
|
|
};
|
|
|
|
|
const mockRes = {
|
|
|
|
|
status: jest.fn().mockReturnThis(),
|
|
|
|
|
json: jest.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await getListAgentsHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(refreshS3Url).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const responseData = mockRes.json.mock.calls[0][0];
|
|
|
|
|
const agent = responseData.data.find((a) => a.id === agentWithS3Avatar.id);
|
|
|
|
|
expect(agent.avatar.filepath).toBe('old-s3-path.jpg');
|
|
|
|
|
});
|
2026-01-14 04:01:11 +10:00
|
|
|
});
|
🪪 fix: Enforce VIEW ACL on Agent Edge References at Write and Runtime (#12246)
* 🛡️ fix: Enforce ACL checks on handoff edge and added-convo agent loading
Edge-linked agents and added-convo agents were fetched by ID via
getAgent without verifying the requesting user's access permissions.
This allowed an authenticated user to reference another user's private
agent in edges or addedConvo and have it initialized at runtime.
Add checkPermission(VIEW) gate in processAgent before initializing
any handoff agent, and in processAddedConvo for non-ephemeral added
agents. Unauthorized agents are logged and added to skippedAgentIds
so orphaned-edge filtering removes them cleanly.
* 🛡️ fix: Validate edge agent access at agent create/update time
Reject agent create/update requests that reference agents in edges
the requesting user cannot VIEW. This provides early feedback and
prevents storing unauthorized agent references as defense-in-depth
alongside the runtime ACL gate in processAgent.
Add collectEdgeAgentIds utility to extract all unique agent IDs from
an edge array, and validateEdgeAgentAccess helper in the v1 handler.
* 🧪 test: Improve ACL gate test coverage and correctness
- Add processAgent ACL gate tests for initializeClient (skip/allow handoff agents)
- Fix addedConvo.spec.js to mock loadAddedAgent directly instead of getAgent
- Seed permMap with ownedAgent VIEW bits in v1.spec.js update-403 test
* 🧹 chore: Remove redundant addedConvo ACL gate (now in middleware)
PR #12243 moved the addedConvo agent ACL check upstream into
canAccessAgentFromBody middleware, making the runtime check in
processAddedConvo and its spec redundant.
* 🧪 test: Rewrite processAgent ACL test with real DB and minimal mocking
Replace heavy mock-based test (12 mocks, Providers.XAI crash) with
MongoMemoryServer-backed integration test that exercises real getAgent,
checkPermission, and AclEntry — only external I/O (initializeAgent,
ToolService, AgentClient) remains mocked. Load edge utilities directly
from packages/api/src/agents/edges to sidestep the config.ts barrel.
* 🧪 fix: Use requireActual spread for @librechat/agents and @librechat/api mocks
The Providers.XAI crash was caused by mocking @librechat/agents with
a minimal replacement object, breaking the @librechat/api initialization
chain. Match the established pattern from client.test.js and
recordCollectedUsage.spec.js: spread jest.requireActual for both
packages, overriding only the functions under test.
2026-03-15 18:08:57 -04:00
|
|
|
|
|
|
|
|
describe('Edge ACL validation', () => {
|
|
|
|
|
let targetAgent;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
targetAgent = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid()}`,
|
|
|
|
|
author: new mongoose.Types.ObjectId().toString(),
|
|
|
|
|
name: 'Target Agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
tools: [],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('createAgentHandler should return 403 when user lacks VIEW on an edge-referenced agent', async () => {
|
|
|
|
|
const permMap = new Map();
|
|
|
|
|
getResourcePermissionsMap.mockResolvedValueOnce(permMap);
|
|
|
|
|
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
name: 'Attacker Agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
edges: [{ from: 'self_placeholder', to: targetAgent.id, edgeType: 'handoff' }],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(403);
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.agent_ids).toContain(targetAgent.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('createAgentHandler should succeed when user has VIEW on all edge-referenced agents', async () => {
|
|
|
|
|
const permMap = new Map([[targetAgent._id.toString(), 1]]);
|
|
|
|
|
getResourcePermissionsMap.mockResolvedValueOnce(permMap);
|
|
|
|
|
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
name: 'Legit Agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
edges: [{ from: 'self_placeholder', to: targetAgent.id, edgeType: 'handoff' }],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('createAgentHandler should allow edges referencing non-existent agents (self-reference at create time)', async () => {
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
name: 'Self-Ref Agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
edges: [{ from: 'agent_does_not_exist_yet', to: 'agent_also_new', edgeType: 'handoff' }],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await createAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('updateAgentHandler should return 403 when user lacks VIEW on an edge-referenced agent', async () => {
|
|
|
|
|
const ownedAgent = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid()}`,
|
|
|
|
|
author: mockReq.user.id,
|
|
|
|
|
name: 'Owned Agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
tools: [],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const permMap = new Map([[ownedAgent._id.toString(), PermissionBits.VIEW]]);
|
|
|
|
|
getResourcePermissionsMap.mockResolvedValueOnce(permMap);
|
|
|
|
|
|
|
|
|
|
mockReq.params = { id: ownedAgent.id };
|
|
|
|
|
mockReq.body = {
|
|
|
|
|
edges: [{ from: ownedAgent.id, to: targetAgent.id, edgeType: 'handoff' }],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(403);
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.agent_ids).toContain(targetAgent.id);
|
|
|
|
|
expect(response.agent_ids).not.toContain(ownedAgent.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('updateAgentHandler should succeed when edges field is absent from payload', async () => {
|
|
|
|
|
const ownedAgent = await Agent.create({
|
|
|
|
|
id: `agent_${nanoid()}`,
|
|
|
|
|
author: mockReq.user.id,
|
|
|
|
|
name: 'Owned Agent',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
model: 'gpt-4',
|
|
|
|
|
tools: [],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
mockReq.params = { id: ownedAgent.id };
|
|
|
|
|
mockReq.body = { name: 'Renamed Agent' };
|
|
|
|
|
|
|
|
|
|
await updateAgentHandler(mockReq, mockRes);
|
|
|
|
|
|
|
|
|
|
expect(mockRes.status).not.toHaveBeenCalledWith(403);
|
|
|
|
|
const response = mockRes.json.mock.calls[0][0];
|
|
|
|
|
expect(response.name).toBe('Renamed Agent');
|
|
|
|
|
});
|
|
|
|
|
});
|
2025-07-05 11:34:28 -04:00
|
|
|
});
|