LibreChat/api/models/Agent.spec.js
Danny Avila 1ecff83b20
🪦 fix: ACL-Safe User Account Deletion for Agents, Prompts, and MCP Servers (#12314)
* fix: use ACL ownership for prompt group cleanup on user deletion

deleteUserPrompts previously called getAllPromptGroups with only an
author filter, which defaults to searchShared=true and drops the
author filter for shared/global project entries. This caused any user
deleting their account to strip shared prompt group associations and
ACL entries for other users.

Replace the author-based query with ACL-based ownership lookup:
- Find prompt groups where the user has OWNER permission (DELETE bit)
- Only delete groups where the user is the sole owner
- Preserve multi-owned groups and their ACL entries for other owners

* fix: use ACL ownership for agent cleanup on user deletion

deleteUserAgents used the deprecated author field to find and delete
agents, then unconditionally removed all ACL entries for those agents.
This could destroy ACL entries for agents shared with or co-owned by
other users.

Replace the author-based query with ACL-based ownership lookup:
- Find agents where the user has OWNER permission (DELETE bit)
- Only delete agents where the user is the sole owner
- Preserve multi-owned agents and their ACL entries for other owners
- Also clean up handoff edges referencing deleted agents

* fix: add MCP server cleanup on user deletion

User deletion had no cleanup for MCP servers, leaving solely-owned
servers orphaned in the database with dangling ACL entries for other
users.

Add deleteUserMcpServers that follows the same ACL ownership pattern
as prompt groups and agents: find servers with OWNER permission,
check for sole ownership, and only delete those with no other owners.

* style: fix prettier formatting in Prompt.spec.js

* refactor: extract getSoleOwnedResourceIds to PermissionService

The ACL sole-ownership detection algorithm was duplicated across
deleteUserPrompts, deleteUserAgents, and deleteUserMcpServers.
Centralizes the three-step pattern (find owned entries, find other
owners, compute sole-owned set) into a single reusable utility.

* refactor: use getSoleOwnedResourceIds in all deletion functions

- Replace inline ACL queries with the centralized utility
- Remove vestigial _req parameter from deleteUserPrompts
- Use Promise.all for parallel project removal instead of sequential awaits
- Disconnect live MCP sessions and invalidate tool cache before deleting
  sole-owned MCP server documents
- Export deleteUserMcpServers for testability

* test: improve deletion test coverage and quality

- Move deleteUserPrompts call to beforeAll to eliminate execution-order
  dependency between tests
- Standardize on test() instead of it() for consistency in Prompt.spec.js
- Add assertion for deleting user's own ACL entry preservation on
  multi-owned agents
- Add deleteUserMcpServers integration test suite with 6 tests covering
  sole-owner deletion, multi-owner preservation, session disconnect,
  cache invalidation, model-not-registered guard, and missing MCPManager
- Add PermissionService mock to existing deleteUser.spec.js to fix
  import chain

* fix: add legacy author-based fallback for unmigrated resources

Resources created before the ACL system have author set but no AclEntry
records. The sole-ownership detection returns empty for these, causing
deleteUserPrompts, deleteUserAgents, and deleteUserMcpServers to silently
skip them — permanently orphaning data on user deletion.

Add a fallback that identifies author-owned resources with zero ACL
entries (truly unmigrated) and includes them in the deletion set. This
preserves the multi-owner safety of the ACL path while ensuring pre-ACL
resources are still cleaned up regardless of migration status.

* style: fix prettier formatting across all changed files

* test: add resource type coverage guard for user deletion

Ensures every ResourceType in the ACL system has a corresponding cleanup
handler wired into deleteUserController. When a new ResourceType is added
(e.g. WORKFLOW), this test fails immediately, preventing silent data
orphaning on user account deletion.

* style: fix import order in PermissionService destructure

* test: add opt-out set and fix test lifecycle in coverage guard

Add NO_USER_CLEANUP_NEEDED set for resource types that legitimately
require no per-user deletion. Move fs.readFileSync into beforeAll so
path errors surface as clean test failures instead of unhandled crashes.
2026-03-19 17:46:14 -04:00

3964 lines
126 KiB
JavaScript

const originalEnv = {
CREDS_KEY: process.env.CREDS_KEY,
CREDS_IV: process.env.CREDS_IV,
};
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
process.env.CREDS_IV = '0123456789abcdef';
jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn(),
getMCPServerTools: jest.fn(),
}));
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { agentSchema } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
ResourceType,
AccessRoleIds,
PrincipalType,
PermissionBits,
} = require('librechat-data-provider');
const {
getAgent,
loadAgent,
createAgent,
updateAgent,
deleteAgent,
deleteUserAgents,
revertAgentVersion,
updateAgentProjects,
addAgentResourceFile,
getListAgentsByAccess,
removeAgentResourceFiles,
generateActionMetadataHash,
} = require('./Agent');
const permissionService = require('~/server/services/PermissionService');
const { getCachedTools, getMCPServerTools } = require('~/server/services/Config');
const { AclEntry, User } = require('~/db/models');
/**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
*/
let Agent;
describe('models/Agent', () => {
describe('Agent Resource File Operations', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
process.env.CREDS_KEY = originalEnv.CREDS_KEY;
process.env.CREDS_IV = originalEnv.CREDS_IV;
});
beforeEach(async () => {
await Agent.deleteMany({});
await User.deleteMany({});
});
test('should add tool_resource to tools if missing', async () => {
const agent = await createBasicAgent();
const fileId = uuidv4();
const toolResource = 'file_search';
const updatedAgent = await addAgentResourceFile({
agent_id: agent.id,
tool_resource: toolResource,
file_id: fileId,
});
expect(updatedAgent.tools).toContain(toolResource);
expect(Array.isArray(updatedAgent.tools)).toBe(true);
// Should not duplicate
const count = updatedAgent.tools.filter((t) => t === toolResource).length;
expect(count).toBe(1);
});
test('should not duplicate tool_resource in tools if already present', async () => {
const agent = await createBasicAgent();
const fileId1 = uuidv4();
const fileId2 = uuidv4();
const toolResource = 'file_search';
// First add
await addAgentResourceFile({
agent_id: agent.id,
tool_resource: toolResource,
file_id: fileId1,
});
// Second add (should not duplicate)
const updatedAgent = await addAgentResourceFile({
agent_id: agent.id,
tool_resource: toolResource,
file_id: fileId2,
});
expect(updatedAgent.tools).toContain(toolResource);
expect(Array.isArray(updatedAgent.tools)).toBe(true);
const count = updatedAgent.tools.filter((t) => t === toolResource).length;
expect(count).toBe(1);
});
test('should handle concurrent file additions', async () => {
const agent = await createBasicAgent();
const fileIds = Array.from({ length: 10 }, () => uuidv4());
// Concurrent additions
const additionPromises = createFileOperations(agent.id, fileIds, 'add');
await Promise.all(additionPromises);
const updatedAgent = await Agent.findOne({ id: agent.id });
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
expect(updatedAgent.tool_resources.test_tool.file_ids).toHaveLength(10);
expect(new Set(updatedAgent.tool_resources.test_tool.file_ids).size).toBe(10);
});
test('should handle concurrent additions and removals', async () => {
const agent = await createBasicAgent();
const initialFileIds = Array.from({ length: 5 }, () => uuidv4());
await Promise.all(createFileOperations(agent.id, initialFileIds, 'add'));
const newFileIds = Array.from({ length: 5 }, () => uuidv4());
const operations = [
...newFileIds.map((fileId) =>
addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: fileId,
}),
),
...initialFileIds.map((fileId) =>
removeAgentResourceFiles({
agent_id: agent.id,
files: [{ tool_resource: 'test_tool', file_id: fileId }],
}),
),
];
await Promise.all(operations);
const updatedAgent = await Agent.findOne({ id: agent.id });
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
expect(updatedAgent.tool_resources.test_tool.file_ids).toHaveLength(5);
});
test('should initialize array when adding to non-existent tool resource', async () => {
const agent = await createBasicAgent();
const fileId = uuidv4();
const updatedAgent = await addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'new_tool',
file_id: fileId,
});
expect(updatedAgent.tool_resources.new_tool.file_ids).toBeDefined();
expect(updatedAgent.tool_resources.new_tool.file_ids).toHaveLength(1);
expect(updatedAgent.tool_resources.new_tool.file_ids[0]).toBe(fileId);
});
test('should handle rapid sequential modifications to same tool resource', async () => {
const agent = await createBasicAgent();
const fileId = uuidv4();
for (let i = 0; i < 10; i++) {
await addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: `${fileId}_${i}`,
});
if (i % 2 === 0) {
await removeAgentResourceFiles({
agent_id: agent.id,
files: [{ tool_resource: 'test_tool', file_id: `${fileId}_${i}` }],
});
}
}
const updatedAgent = await Agent.findOne({ id: agent.id });
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
expect(Array.isArray(updatedAgent.tool_resources.test_tool.file_ids)).toBe(true);
});
test('should handle multiple tool resources concurrently', async () => {
const agent = await createBasicAgent();
const toolResources = ['tool1', 'tool2', 'tool3'];
const operations = [];
toolResources.forEach((tool) => {
const fileIds = Array.from({ length: 5 }, () => uuidv4());
fileIds.forEach((fileId) => {
operations.push(
addAgentResourceFile({
agent_id: agent.id,
tool_resource: tool,
file_id: fileId,
}),
);
});
});
await Promise.all(operations);
const updatedAgent = await Agent.findOne({ id: agent.id });
toolResources.forEach((tool) => {
expect(updatedAgent.tool_resources[tool].file_ids).toBeDefined();
expect(updatedAgent.tool_resources[tool].file_ids).toHaveLength(5);
});
});
test.each([
{
name: 'duplicate additions',
operation: 'add',
duplicateCount: 5,
expectedLength: 1,
expectedContains: true,
},
{
name: 'duplicate removals',
operation: 'remove',
duplicateCount: 5,
expectedLength: 0,
expectedContains: false,
setupFile: true,
},
])(
'should handle concurrent $name',
async ({ operation, duplicateCount, expectedLength, expectedContains, setupFile }) => {
const agent = await createBasicAgent();
const fileId = uuidv4();
if (setupFile) {
await addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: fileId,
});
}
const promises = Array.from({ length: duplicateCount }).map(() =>
operation === 'add'
? addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: fileId,
})
: removeAgentResourceFiles({
agent_id: agent.id,
files: [{ tool_resource: 'test_tool', file_id: fileId }],
}),
);
await Promise.all(promises);
const updatedAgent = await Agent.findOne({ id: agent.id });
const fileIds = updatedAgent.tool_resources?.test_tool?.file_ids ?? [];
expect(fileIds).toHaveLength(expectedLength);
if (expectedContains) {
expect(fileIds[0]).toBe(fileId);
} else {
expect(fileIds).not.toContain(fileId);
}
},
);
test('should handle concurrent add and remove of the same file', async () => {
const agent = await createBasicAgent();
const fileId = uuidv4();
await addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: fileId,
});
const operations = [
addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: fileId,
}),
removeAgentResourceFiles({
agent_id: agent.id,
files: [{ tool_resource: 'test_tool', file_id: fileId }],
}),
];
await Promise.all(operations);
const updatedAgent = await Agent.findOne({ id: agent.id });
const finalFileIds = updatedAgent.tool_resources.test_tool.file_ids;
const count = finalFileIds.filter((id) => id === fileId).length;
expect(count).toBeLessThanOrEqual(1);
if (count === 0) {
expect(finalFileIds).toHaveLength(0);
} else {
expect(finalFileIds).toHaveLength(1);
expect(finalFileIds[0]).toBe(fileId);
}
});
test('should handle concurrent removals of different files', async () => {
const agent = await createBasicAgent();
const fileIds = Array.from({ length: 10 }, () => uuidv4());
// Add all files first
await Promise.all(
fileIds.map((fileId) =>
addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: fileId,
}),
),
);
// Concurrently remove all files
const removalPromises = fileIds.map((fileId) =>
removeAgentResourceFiles({
agent_id: agent.id,
files: [{ tool_resource: 'test_tool', file_id: fileId }],
}),
);
await Promise.all(removalPromises);
const updatedAgent = await Agent.findOne({ id: agent.id });
// Check if the array is empty or the tool resource itself is removed
const finalFileIds = updatedAgent.tool_resources?.test_tool?.file_ids ?? [];
expect(finalFileIds).toHaveLength(0);
});
describe('Edge Cases', () => {
describe.each([
{
operation: 'add',
name: 'empty file_id',
needsAgent: true,
params: { tool_resource: 'file_search', file_id: '' },
shouldResolve: true,
},
{
operation: 'add',
name: 'non-existent agent',
needsAgent: false,
params: { tool_resource: 'file_search', file_id: 'file123' },
shouldResolve: false,
error: 'Agent not found for adding resource file',
},
])('addAgentResourceFile with $name', ({ needsAgent, params, shouldResolve, error }) => {
test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
const agent = needsAgent ? await createBasicAgent() : null;
const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
if (shouldResolve) {
await expect(addAgentResourceFile({ agent_id, ...params })).resolves.toBeDefined();
} else {
await expect(addAgentResourceFile({ agent_id, ...params })).rejects.toThrow(error);
}
});
});
describe.each([
{
name: 'empty files array',
files: [],
needsAgent: true,
shouldResolve: true,
},
{
name: 'non-existent tool_resource',
files: [{ tool_resource: 'non_existent_tool', file_id: 'file123' }],
needsAgent: true,
shouldResolve: true,
},
{
name: 'non-existent agent',
files: [{ tool_resource: 'file_search', file_id: 'file123' }],
needsAgent: false,
shouldResolve: false,
error: 'Agent not found for removing resource files',
},
])('removeAgentResourceFiles with $name', ({ files, needsAgent, shouldResolve, error }) => {
test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
const agent = needsAgent ? await createBasicAgent() : null;
const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
if (shouldResolve) {
const result = await removeAgentResourceFiles({ agent_id, files });
expect(result).toBeDefined();
if (agent) {
expect(result.id).toBe(agent.id);
}
} else {
await expect(removeAgentResourceFiles({ agent_id, files })).rejects.toThrow(error);
}
});
});
});
});
describe('Agent CRUD Operations', () => {
let mongoServer;
let AccessRole;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
// Initialize models
const dbModels = require('~/db/models');
AccessRole = dbModels.AccessRole;
// Create necessary access roles for agents
await AccessRole.create({
accessRoleId: AccessRoleIds.AGENT_OWNER,
name: 'Owner',
description: 'Full control over agents',
resourceType: ResourceType.AGENT,
permBits: 15, // VIEW | EDIT | DELETE | SHARE
});
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
await AclEntry.deleteMany({});
});
test('should create and get an agent', async () => {
const { agentId, authorId } = createTestIds();
const newAgent = await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
description: 'Test description',
});
expect(newAgent).toBeDefined();
expect(newAgent.id).toBe(agentId);
expect(newAgent.name).toBe('Test Agent');
const retrievedAgent = await getAgent({ id: agentId });
expect(retrievedAgent).toBeDefined();
expect(retrievedAgent.id).toBe(agentId);
expect(retrievedAgent.name).toBe('Test Agent');
expect(retrievedAgent.description).toBe('Test description');
});
test('should delete an agent', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
const agentBeforeDelete = await getAgent({ id: agentId });
expect(agentBeforeDelete).toBeDefined();
await deleteAgent({ id: agentId });
const agentAfterDelete = await getAgent({ id: agentId });
expect(agentAfterDelete).toBeNull();
});
test('should remove ACL entries when deleting an agent', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent
const agent = await createAgent({
id: agentId,
name: 'Agent With Permissions',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Grant permissions (simulating sharing)
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: authorId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: authorId,
});
// Verify ACL entry exists
const aclEntriesBefore = await AclEntry.find({
resourceType: ResourceType.AGENT,
resourceId: agent._id,
});
expect(aclEntriesBefore).toHaveLength(1);
// Delete the agent
await deleteAgent({ id: agentId });
// Verify agent is deleted
const agentAfterDelete = await getAgent({ id: agentId });
expect(agentAfterDelete).toBeNull();
// Verify ACL entries are removed
const aclEntriesAfter = await AclEntry.find({
resourceType: ResourceType.AGENT,
resourceId: agent._id,
});
expect(aclEntriesAfter).toHaveLength(0);
});
test('should remove handoff edges referencing deleted agent from other agents', async () => {
const authorId = new mongoose.Types.ObjectId();
const targetAgentId = `agent_${uuidv4()}`;
const sourceAgentId = `agent_${uuidv4()}`;
// Create target agent (handoff destination)
await createAgent({
id: targetAgentId,
name: 'Target Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create source agent with handoff edge to target
await createAgent({
id: sourceAgentId,
name: 'Source Agent',
provider: 'test',
model: 'test-model',
author: authorId,
edges: [
{
from: sourceAgentId,
to: targetAgentId,
edgeType: 'handoff',
},
],
});
// Verify edge exists before deletion
const sourceAgentBefore = await getAgent({ id: sourceAgentId });
expect(sourceAgentBefore.edges).toHaveLength(1);
expect(sourceAgentBefore.edges[0].to).toBe(targetAgentId);
// Delete the target agent
await deleteAgent({ id: targetAgentId });
// Verify the edge is removed from source agent
const sourceAgentAfter = await getAgent({ id: sourceAgentId });
expect(sourceAgentAfter.edges).toHaveLength(0);
});
test('should remove agent from user favorites when agent is deleted', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
// Create agent
await createAgent({
id: agentId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create user with the agent in favorites
await User.create({
_id: userId,
name: 'Test User',
email: `test-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agentId }, { model: 'gpt-4', endpoint: 'openAI' }],
});
// Verify user has agent in favorites
const userBefore = await User.findById(userId);
expect(userBefore.favorites).toHaveLength(2);
expect(userBefore.favorites.some((f) => f.agentId === agentId)).toBe(true);
// Delete the agent
await deleteAgent({ id: agentId });
// Verify agent is deleted
const agentAfterDelete = await getAgent({ id: agentId });
expect(agentAfterDelete).toBeNull();
// Verify agent is removed from user favorites
const userAfter = await User.findById(userId);
expect(userAfter.favorites).toHaveLength(1);
expect(userAfter.favorites.some((f) => f.agentId === agentId)).toBe(false);
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
});
test('should remove agent from multiple users favorites when agent is deleted', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const user1Id = new mongoose.Types.ObjectId();
const user2Id = new mongoose.Types.ObjectId();
// Create agent
await createAgent({
id: agentId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create two users with the agent in favorites
await User.create({
_id: user1Id,
name: 'Test User 1',
email: `test1-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agentId }],
});
await User.create({
_id: user2Id,
name: 'Test User 2',
email: `test2-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agentId }, { agentId: `agent_${uuidv4()}` }],
});
// Delete the agent
await deleteAgent({ id: agentId });
// Verify agent is removed from both users' favorites
const user1After = await User.findById(user1Id);
const user2After = await User.findById(user2Id);
expect(user1After.favorites).toHaveLength(0);
expect(user2After.favorites).toHaveLength(1);
expect(user2After.favorites.some((f) => f.agentId === agentId)).toBe(false);
});
test('should preserve other agents in database when one agent is deleted', async () => {
const agentToDeleteId = `agent_${uuidv4()}`;
const agentToKeep1Id = `agent_${uuidv4()}`;
const agentToKeep2Id = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create multiple agents
await createAgent({
id: agentToDeleteId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: agentToKeep1Id,
name: 'Agent To Keep 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: agentToKeep2Id,
name: 'Agent To Keep 2',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Verify all agents exist
expect(await getAgent({ id: agentToDeleteId })).not.toBeNull();
expect(await getAgent({ id: agentToKeep1Id })).not.toBeNull();
expect(await getAgent({ id: agentToKeep2Id })).not.toBeNull();
// Delete one agent
await deleteAgent({ id: agentToDeleteId });
// Verify only the deleted agent is removed, others remain intact
expect(await getAgent({ id: agentToDeleteId })).toBeNull();
const keptAgent1 = await getAgent({ id: agentToKeep1Id });
const keptAgent2 = await getAgent({ id: agentToKeep2Id });
expect(keptAgent1).not.toBeNull();
expect(keptAgent1.name).toBe('Agent To Keep 1');
expect(keptAgent2).not.toBeNull();
expect(keptAgent2.name).toBe('Agent To Keep 2');
});
test('should preserve other agents in user favorites when one agent is deleted', async () => {
const agentToDeleteId = `agent_${uuidv4()}`;
const agentToKeep1Id = `agent_${uuidv4()}`;
const agentToKeep2Id = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
// Create multiple agents
await createAgent({
id: agentToDeleteId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: agentToKeep1Id,
name: 'Agent To Keep 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: agentToKeep2Id,
name: 'Agent To Keep 2',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create user with all three agents in favorites
await User.create({
_id: userId,
name: 'Test User',
email: `test-${uuidv4()}@example.com`,
provider: 'local',
favorites: [
{ agentId: agentToDeleteId },
{ agentId: agentToKeep1Id },
{ agentId: agentToKeep2Id },
],
});
// Verify user has all three agents in favorites
const userBefore = await User.findById(userId);
expect(userBefore.favorites).toHaveLength(3);
// Delete one agent
await deleteAgent({ id: agentToDeleteId });
// Verify only the deleted agent is removed from favorites
const userAfter = await User.findById(userId);
expect(userAfter.favorites).toHaveLength(2);
expect(userAfter.favorites.some((f) => f.agentId === agentToDeleteId)).toBe(false);
expect(userAfter.favorites.some((f) => f.agentId === agentToKeep1Id)).toBe(true);
expect(userAfter.favorites.some((f) => f.agentId === agentToKeep2Id)).toBe(true);
});
test('should not affect users who do not have deleted agent in favorites', async () => {
const agentToDeleteId = `agent_${uuidv4()}`;
const otherAgentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const userWithDeletedAgentId = new mongoose.Types.ObjectId();
const userWithoutDeletedAgentId = new mongoose.Types.ObjectId();
// Create agents
await createAgent({
id: agentToDeleteId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: otherAgentId,
name: 'Other Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create user with the agent to be deleted
await User.create({
_id: userWithDeletedAgentId,
name: 'User With Deleted Agent',
email: `user1-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agentToDeleteId }, { model: 'gpt-4', endpoint: 'openAI' }],
});
// Create user without the agent to be deleted
await User.create({
_id: userWithoutDeletedAgentId,
name: 'User Without Deleted Agent',
email: `user2-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: otherAgentId }, { model: 'claude-3', endpoint: 'anthropic' }],
});
// Delete the agent
await deleteAgent({ id: agentToDeleteId });
// Verify user with deleted agent has it removed
const userWithDeleted = await User.findById(userWithDeletedAgentId);
expect(userWithDeleted.favorites).toHaveLength(1);
expect(userWithDeleted.favorites.some((f) => f.agentId === agentToDeleteId)).toBe(false);
expect(userWithDeleted.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
// Verify user without deleted agent is completely unaffected
const userWithoutDeleted = await User.findById(userWithoutDeletedAgentId);
expect(userWithoutDeleted.favorites).toHaveLength(2);
expect(userWithoutDeleted.favorites.some((f) => f.agentId === otherAgentId)).toBe(true);
expect(userWithoutDeleted.favorites.some((f) => f.model === 'claude-3')).toBe(true);
});
test('should remove all user agents from favorites when deleteUserAgents is called', async () => {
const authorId = new mongoose.Types.ObjectId();
const otherAuthorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agent1Id = `agent_${uuidv4()}`;
const agent2Id = `agent_${uuidv4()}`;
const otherAuthorAgentId = `agent_${uuidv4()}`;
const agent1 = await createAgent({
id: agent1Id,
name: 'Author Agent 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
const agent2 = await createAgent({
id: agent2Id,
name: 'Author Agent 2',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: otherAuthorAgentId,
name: 'Other Author Agent',
provider: 'test',
model: 'test-model',
author: otherAuthorId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: authorId,
resourceType: ResourceType.AGENT,
resourceId: agent1._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: authorId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: authorId,
resourceType: ResourceType.AGENT,
resourceId: agent2._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: authorId,
});
await User.create({
_id: userId,
name: 'Test User',
email: `test-${uuidv4()}@example.com`,
provider: 'local',
favorites: [
{ agentId: agent1Id },
{ agentId: agent2Id },
{ agentId: otherAuthorAgentId },
{ model: 'gpt-4', endpoint: 'openAI' },
],
});
const userBefore = await User.findById(userId);
expect(userBefore.favorites).toHaveLength(4);
await deleteUserAgents(authorId.toString());
expect(await getAgent({ id: agent1Id })).toBeNull();
expect(await getAgent({ id: agent2Id })).toBeNull();
expect(await getAgent({ id: otherAuthorAgentId })).not.toBeNull();
const userAfter = await User.findById(userId);
expect(userAfter.favorites).toHaveLength(2);
expect(userAfter.favorites.some((f) => f.agentId === agent1Id)).toBe(false);
expect(userAfter.favorites.some((f) => f.agentId === agent2Id)).toBe(false);
expect(userAfter.favorites.some((f) => f.agentId === otherAuthorAgentId)).toBe(true);
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
});
test('should handle deleteUserAgents when agents are in multiple users favorites', async () => {
const authorId = new mongoose.Types.ObjectId();
const user1Id = new mongoose.Types.ObjectId();
const user2Id = new mongoose.Types.ObjectId();
const user3Id = new mongoose.Types.ObjectId();
const agent1Id = `agent_${uuidv4()}`;
const agent2Id = `agent_${uuidv4()}`;
const unrelatedAgentId = `agent_${uuidv4()}`;
const agent1 = await createAgent({
id: agent1Id,
name: 'Author Agent 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
const agent2 = await createAgent({
id: agent2Id,
name: 'Author Agent 2',
provider: 'test',
model: 'test-model',
author: authorId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: authorId,
resourceType: ResourceType.AGENT,
resourceId: agent1._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: authorId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: authorId,
resourceType: ResourceType.AGENT,
resourceId: agent2._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: authorId,
});
await User.create({
_id: user1Id,
name: 'User 1',
email: `user1-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agent1Id }, { agentId: agent2Id }],
});
await User.create({
_id: user2Id,
name: 'User 2',
email: `user2-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agent1Id }, { model: 'claude-3', endpoint: 'anthropic' }],
});
await User.create({
_id: user3Id,
name: 'User 3',
email: `user3-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: unrelatedAgentId }, { model: 'gpt-4', endpoint: 'openAI' }],
});
await deleteUserAgents(authorId.toString());
const user1After = await User.findById(user1Id);
expect(user1After.favorites).toHaveLength(0);
const user2After = await User.findById(user2Id);
expect(user2After.favorites).toHaveLength(1);
expect(user2After.favorites.some((f) => f.agentId === agent1Id)).toBe(false);
expect(user2After.favorites.some((f) => f.model === 'claude-3')).toBe(true);
const user3After = await User.findById(user3Id);
expect(user3After.favorites).toHaveLength(2);
expect(user3After.favorites.some((f) => f.agentId === unrelatedAgentId)).toBe(true);
expect(user3After.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
});
test('should handle deleteUserAgents when user has no agents', async () => {
const authorWithNoAgentsId = new mongoose.Types.ObjectId();
const otherAuthorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const existingAgentId = `agent_${uuidv4()}`;
const existingAgent = await createAgent({
id: existingAgentId,
name: 'Existing Agent',
provider: 'test',
model: 'test-model',
author: otherAuthorId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: otherAuthorId,
resourceType: ResourceType.AGENT,
resourceId: existingAgent._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: otherAuthorId,
});
await User.create({
_id: userId,
name: 'Test User',
email: `test-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: existingAgentId }, { model: 'gpt-4', endpoint: 'openAI' }],
});
await deleteUserAgents(authorWithNoAgentsId.toString());
expect(await getAgent({ id: existingAgentId })).not.toBeNull();
const userAfter = await User.findById(userId);
expect(userAfter.favorites).toHaveLength(2);
expect(userAfter.favorites.some((f) => f.agentId === existingAgentId)).toBe(true);
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
});
test('should handle deleteUserAgents when agents are not in any favorites', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agent1Id = `agent_${uuidv4()}`;
const agent2Id = `agent_${uuidv4()}`;
const agent1 = await createAgent({
id: agent1Id,
name: 'Agent 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
const agent2 = await createAgent({
id: agent2Id,
name: 'Agent 2',
provider: 'test',
model: 'test-model',
author: authorId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: authorId,
resourceType: ResourceType.AGENT,
resourceId: agent1._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: authorId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: authorId,
resourceType: ResourceType.AGENT,
resourceId: agent2._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: authorId,
});
await User.create({
_id: userId,
name: 'Test User',
email: `test-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ model: 'gpt-4', endpoint: 'openAI' }],
});
expect(await getAgent({ id: agent1Id })).not.toBeNull();
expect(await getAgent({ id: agent2Id })).not.toBeNull();
await deleteUserAgents(authorId.toString());
expect(await getAgent({ id: agent1Id })).toBeNull();
expect(await getAgent({ id: agent2Id })).toBeNull();
const userAfter = await User.findById(userId);
expect(userAfter.favorites).toHaveLength(1);
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
});
test('should preserve multi-owned agents when deleteUserAgents is called', async () => {
const deletingUserId = new mongoose.Types.ObjectId();
const otherOwnerId = new mongoose.Types.ObjectId();
const soleOwnedId = `agent_${uuidv4()}`;
const multiOwnedId = `agent_${uuidv4()}`;
const soleAgent = await createAgent({
id: soleOwnedId,
name: 'Sole Owned Agent',
provider: 'test',
model: 'test-model',
author: deletingUserId,
});
const multiAgent = await createAgent({
id: multiOwnedId,
name: 'Multi Owned Agent',
provider: 'test',
model: 'test-model',
author: deletingUserId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: deletingUserId,
resourceType: ResourceType.AGENT,
resourceId: soleAgent._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: deletingUserId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: deletingUserId,
resourceType: ResourceType.AGENT,
resourceId: multiAgent._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: deletingUserId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: otherOwnerId,
resourceType: ResourceType.AGENT,
resourceId: multiAgent._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: otherOwnerId,
});
await deleteUserAgents(deletingUserId.toString());
expect(await getAgent({ id: soleOwnedId })).toBeNull();
expect(await getAgent({ id: multiOwnedId })).not.toBeNull();
const soleAcl = await AclEntry.find({
resourceType: ResourceType.AGENT,
resourceId: soleAgent._id,
});
expect(soleAcl).toHaveLength(0);
const multiAcl = await AclEntry.find({
resourceType: ResourceType.AGENT,
resourceId: multiAgent._id,
principalId: otherOwnerId,
});
expect(multiAcl).toHaveLength(1);
expect(multiAcl[0].permBits & PermissionBits.DELETE).toBeTruthy();
const deletingUserMultiAcl = await AclEntry.find({
resourceType: ResourceType.AGENT,
resourceId: multiAgent._id,
principalId: deletingUserId,
});
expect(deletingUserMultiAcl).toHaveLength(1);
});
test('should delete legacy agents that have author but no ACL entries', async () => {
const legacyUserId = new mongoose.Types.ObjectId();
const legacyAgentId = `agent_${uuidv4()}`;
await createAgent({
id: legacyAgentId,
name: 'Legacy Agent (no ACL)',
provider: 'test',
model: 'test-model',
author: legacyUserId,
});
await deleteUserAgents(legacyUserId.toString());
expect(await getAgent({ id: legacyAgentId })).toBeNull();
});
test('should update agent projects', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
const projectId3 = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Project Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
projectIds: [projectId1],
});
await updateAgent(
{ id: agentId },
{ $addToSet: { projectIds: { $each: [projectId2, projectId3] } } },
);
await updateAgent({ id: agentId }, { $pull: { projectIds: projectId1 } });
await updateAgent({ id: agentId }, { projectIds: [projectId2, projectId3] });
const updatedAgent = await getAgent({ id: agentId });
expect(updatedAgent.projectIds).toHaveLength(2);
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId3.toString());
expect(updatedAgent.projectIds.map((id) => id.toString())).not.toContain(
projectId1.toString(),
);
await updateAgent({ id: agentId }, { projectIds: [] });
const emptyProjectsAgent = await getAgent({ id: agentId });
expect(emptyProjectsAgent.projectIds).toHaveLength(0);
const nonExistentId = `agent_${uuidv4()}`;
await expect(
updateAgentProjects({
id: nonExistentId,
projectIds: [projectId1],
}),
).rejects.toThrow();
});
test('should handle ephemeral agent loading', async () => {
const agentId = 'ephemeral_test';
const endpoint = 'openai';
const originalModule = jest.requireActual('librechat-data-provider');
const mockDataProvider = {
...originalModule,
Constants: {
...originalModule.Constants,
EPHEMERAL_AGENT_ID: 'ephemeral_test',
},
};
jest.doMock('librechat-data-provider', () => mockDataProvider);
expect(agentId).toBeDefined();
expect(endpoint).toBeDefined();
jest.dontMock('librechat-data-provider');
});
test('should handle loadAgent functionality and errors', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Load Agent',
provider: 'test',
model: 'test-model',
author: authorId,
tools: ['tool1', 'tool2'],
});
const agent = await getAgent({ id: agentId });
expect(agent).toBeDefined();
expect(agent.id).toBe(agentId);
expect(agent.name).toBe('Test Load Agent');
expect(agent.tools).toEqual(expect.arrayContaining(['tool1', 'tool2']));
const mockLoadAgent = jest.fn().mockResolvedValue(agent);
const loadedAgent = await mockLoadAgent();
expect(loadedAgent).toBeDefined();
expect(loadedAgent.id).toBe(agentId);
const nonExistentId = `agent_${uuidv4()}`;
const nonExistentAgent = await getAgent({ id: nonExistentId });
expect(nonExistentAgent).toBeNull();
const mockLoadAgentError = jest.fn().mockRejectedValue(new Error('No agent found with ID'));
await expect(mockLoadAgentError()).rejects.toThrow('No agent found with ID');
});
describe('Edge Cases', () => {
test.each([
{
name: 'getAgent with undefined search parameters',
fn: () => getAgent(undefined),
expected: null,
},
{
name: 'deleteAgent with non-existent agent',
fn: () => deleteAgent({ id: 'non-existent' }),
expected: null,
},
])('$name should return null', async ({ fn, expected }) => {
const result = await fn();
expect(result).toBe(expected);
});
test('should handle updateAgentProjects with non-existent agent', async () => {
const nonExistentId = `agent_${uuidv4()}`;
const userId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
const result = await updateAgentProjects({
user: { id: userId.toString() },
agentId: nonExistentId,
projectIds: [projectId.toString()],
});
expect(result).toBeNull();
});
});
});
describe('Agent Version History', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
test('should create an agent with a single entry in versions array', async () => {
const agent = await createBasicAgent();
expect(agent.versions).toBeDefined();
expect(Array.isArray(agent.versions)).toBe(true);
expect(agent.versions).toHaveLength(1);
expect(agent.versions[0].name).toBe('Test Agent');
expect(agent.versions[0].provider).toBe('test');
expect(agent.versions[0].model).toBe('test-model');
});
test('should accumulate version history across multiple updates', async () => {
const agentId = `agent_${uuidv4()}`;
const author = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'First Name',
provider: 'test',
model: 'test-model',
author,
description: 'First description',
});
await updateAgent(
{ id: agentId },
{ name: 'Second Name', description: 'Second description' },
);
await updateAgent({ id: agentId }, { name: 'Third Name', model: 'new-model' });
const finalAgent = await updateAgent({ id: agentId }, { description: 'Final description' });
expect(finalAgent.versions).toBeDefined();
expect(Array.isArray(finalAgent.versions)).toBe(true);
expect(finalAgent.versions).toHaveLength(4);
expect(finalAgent.versions[0].name).toBe('First Name');
expect(finalAgent.versions[0].description).toBe('First description');
expect(finalAgent.versions[0].model).toBe('test-model');
expect(finalAgent.versions[1].name).toBe('Second Name');
expect(finalAgent.versions[1].description).toBe('Second description');
expect(finalAgent.versions[1].model).toBe('test-model');
expect(finalAgent.versions[2].name).toBe('Third Name');
expect(finalAgent.versions[2].description).toBe('Second description');
expect(finalAgent.versions[2].model).toBe('new-model');
expect(finalAgent.versions[3].name).toBe('Third Name');
expect(finalAgent.versions[3].description).toBe('Final description');
expect(finalAgent.versions[3].model).toBe('new-model');
expect(finalAgent.name).toBe('Third Name');
expect(finalAgent.description).toBe('Final description');
expect(finalAgent.model).toBe('new-model');
});
test('should not include metadata fields in version history', async () => {
const agentId = `agent_${uuidv4()}`;
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: new mongoose.Types.ObjectId(),
});
const updatedAgent = await updateAgent({ id: agentId }, { description: 'New description' });
expect(updatedAgent.versions).toHaveLength(2);
expect(updatedAgent.versions[0]._id).toBeUndefined();
expect(updatedAgent.versions[0].__v).toBeUndefined();
expect(updatedAgent.versions[0].name).toBe('Test Agent');
expect(updatedAgent.versions[0].author).toBeUndefined();
expect(updatedAgent.versions[1]._id).toBeUndefined();
expect(updatedAgent.versions[1].__v).toBeUndefined();
});
test('should not recursively include previous versions', async () => {
const agentId = `agent_${uuidv4()}`;
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: new mongoose.Types.ObjectId(),
});
await updateAgent({ id: agentId }, { name: 'Updated Name 1' });
await updateAgent({ id: agentId }, { name: 'Updated Name 2' });
const finalAgent = await updateAgent({ id: agentId }, { name: 'Updated Name 3' });
expect(finalAgent.versions).toHaveLength(4);
finalAgent.versions.forEach((version) => {
expect(version.versions).toBeUndefined();
});
});
test('should handle MongoDB operators and field updates correctly', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'MongoDB Operator Test',
provider: 'test',
model: 'test-model',
author: authorId,
tools: ['tool1'],
});
await updateAgent(
{ id: agentId },
{
description: 'Updated description',
$push: { tools: 'tool2' },
$addToSet: { projectIds: projectId },
},
);
const firstUpdate = await getAgent({ id: agentId });
expect(firstUpdate.description).toBe('Updated description');
expect(firstUpdate.tools).toContain('tool1');
expect(firstUpdate.tools).toContain('tool2');
expect(firstUpdate.projectIds.map((id) => id.toString())).toContain(projectId.toString());
expect(firstUpdate.versions).toHaveLength(2);
await updateAgent(
{ id: agentId },
{
tools: ['tool2', 'tool3'],
},
);
const secondUpdate = await getAgent({ id: agentId });
expect(secondUpdate.tools).toHaveLength(2);
expect(secondUpdate.tools).toContain('tool2');
expect(secondUpdate.tools).toContain('tool3');
expect(secondUpdate.tools).not.toContain('tool1');
expect(secondUpdate.versions).toHaveLength(3);
await updateAgent(
{ id: agentId },
{
$push: { tools: 'tool3' },
},
);
const thirdUpdate = await getAgent({ id: agentId });
const toolCount = thirdUpdate.tools.filter((t) => t === 'tool3').length;
expect(toolCount).toBe(2);
expect(thirdUpdate.versions).toHaveLength(4);
});
test('should handle parameter objects correctly', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Parameters Test',
provider: 'test',
model: 'test-model',
author: authorId,
model_parameters: { temperature: 0.7 },
});
const updatedAgent = await updateAgent(
{ id: agentId },
{ model_parameters: { temperature: 0.8 } },
);
expect(updatedAgent.versions).toHaveLength(2);
expect(updatedAgent.model_parameters.temperature).toBe(0.8);
await updateAgent(
{ id: agentId },
{
model_parameters: {
temperature: 0.8,
max_tokens: 1000,
},
},
);
const complexAgent = await getAgent({ id: agentId });
expect(complexAgent.versions).toHaveLength(3);
expect(complexAgent.model_parameters.temperature).toBe(0.8);
expect(complexAgent.model_parameters.max_tokens).toBe(1000);
await updateAgent({ id: agentId }, { model_parameters: {} });
const emptyParamsAgent = await getAgent({ id: agentId });
expect(emptyParamsAgent.versions).toHaveLength(4);
expect(emptyParamsAgent.model_parameters).toEqual({});
});
test('should not create new version for duplicate updates', async () => {
const authorId = new mongoose.Types.ObjectId();
const testCases = generateVersionTestCases();
for (const testCase of testCases) {
const testAgentId = `agent_${uuidv4()}`;
await createAgent({
id: testAgentId,
provider: 'test',
model: 'test-model',
author: authorId,
...testCase.initial,
});
const updatedAgent = await updateAgent({ id: testAgentId }, testCase.update);
expect(updatedAgent.versions).toHaveLength(2); // No new version created
// Update with duplicate data should succeed but not create a new version
const duplicateUpdate = await updateAgent({ id: testAgentId }, testCase.duplicate);
expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
const agent = await getAgent({ id: testAgentId });
expect(agent.versions).toHaveLength(2);
}
});
test('should track updatedBy when a different user updates an agent', async () => {
const agentId = `agent_${uuidv4()}`;
const originalAuthor = new mongoose.Types.ObjectId();
const updatingUser = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Original Agent',
provider: 'test',
model: 'test-model',
author: originalAuthor,
description: 'Original description',
});
const updatedAgent = await updateAgent(
{ id: agentId },
{ name: 'Updated Agent', description: 'Updated description' },
{ updatingUserId: updatingUser.toString() },
);
expect(updatedAgent.versions).toHaveLength(2);
expect(updatedAgent.versions[1].updatedBy.toString()).toBe(updatingUser.toString());
expect(updatedAgent.author.toString()).toBe(originalAuthor.toString());
});
test('should include updatedBy even when the original author updates the agent', async () => {
const agentId = `agent_${uuidv4()}`;
const originalAuthor = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Original Agent',
provider: 'test',
model: 'test-model',
author: originalAuthor,
description: 'Original description',
});
const updatedAgent = await updateAgent(
{ id: agentId },
{ name: 'Updated Agent', description: 'Updated description' },
{ updatingUserId: originalAuthor.toString() },
);
expect(updatedAgent.versions).toHaveLength(2);
expect(updatedAgent.versions[1].updatedBy.toString()).toBe(originalAuthor.toString());
expect(updatedAgent.author.toString()).toBe(originalAuthor.toString());
});
test('should track multiple different users updating the same agent', async () => {
const agentId = `agent_${uuidv4()}`;
const originalAuthor = new mongoose.Types.ObjectId();
const user1 = new mongoose.Types.ObjectId();
const user2 = new mongoose.Types.ObjectId();
const user3 = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Original Agent',
provider: 'test',
model: 'test-model',
author: originalAuthor,
description: 'Original description',
});
// User 1 makes an update
await updateAgent(
{ id: agentId },
{ name: 'Updated by User 1', description: 'First update' },
{ updatingUserId: user1.toString() },
);
// Original author makes an update
await updateAgent(
{ id: agentId },
{ description: 'Updated by original author' },
{ updatingUserId: originalAuthor.toString() },
);
// User 2 makes an update
await updateAgent(
{ id: agentId },
{ name: 'Updated by User 2', model: 'new-model' },
{ updatingUserId: user2.toString() },
);
// User 3 makes an update
const finalAgent = await updateAgent(
{ id: agentId },
{ description: 'Final update by User 3' },
{ updatingUserId: user3.toString() },
);
expect(finalAgent.versions).toHaveLength(5);
expect(finalAgent.author.toString()).toBe(originalAuthor.toString());
// Check that each version has the correct updatedBy
expect(finalAgent.versions[0].updatedBy).toBeUndefined(); // Initial creation has no updatedBy
expect(finalAgent.versions[1].updatedBy.toString()).toBe(user1.toString());
expect(finalAgent.versions[2].updatedBy.toString()).toBe(originalAuthor.toString());
expect(finalAgent.versions[3].updatedBy.toString()).toBe(user2.toString());
expect(finalAgent.versions[4].updatedBy.toString()).toBe(user3.toString());
// Verify the final state
expect(finalAgent.name).toBe('Updated by User 2');
expect(finalAgent.description).toBe('Final update by User 3');
expect(finalAgent.model).toBe('new-model');
});
test('should preserve original author during agent restoration', async () => {
const agentId = `agent_${uuidv4()}`;
const originalAuthor = new mongoose.Types.ObjectId();
const updatingUser = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Original Agent',
provider: 'test',
model: 'test-model',
author: originalAuthor,
description: 'Original description',
});
await updateAgent(
{ id: agentId },
{ name: 'Updated Agent', description: 'Updated description' },
{ updatingUserId: updatingUser.toString() },
);
const { revertAgentVersion } = require('./Agent');
const revertedAgent = await revertAgentVersion({ id: agentId }, 0);
expect(revertedAgent.author.toString()).toBe(originalAuthor.toString());
expect(revertedAgent.name).toBe('Original Agent');
expect(revertedAgent.description).toBe('Original description');
});
test('should detect action metadata changes and force version update', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const actionId = 'testActionId123';
// Create agent with actions
await createAgent({
id: agentId,
name: 'Agent with Actions',
provider: 'test',
model: 'test-model',
author: authorId,
actions: [`test.com_action_${actionId}`],
tools: ['listEvents_action_test.com', 'createEvent_action_test.com'],
});
// First update with forceVersion should create a version
const firstUpdate = await updateAgent(
{ id: agentId },
{ tools: ['listEvents_action_test.com', 'createEvent_action_test.com'] },
{ updatingUserId: authorId.toString(), forceVersion: true },
);
expect(firstUpdate.versions).toHaveLength(2);
// Second update with same data but forceVersion should still create a version
const secondUpdate = await updateAgent(
{ id: agentId },
{ tools: ['listEvents_action_test.com', 'createEvent_action_test.com'] },
{ updatingUserId: authorId.toString(), forceVersion: true },
);
expect(secondUpdate.versions).toHaveLength(3);
// Update without forceVersion and no changes should not create a version
const duplicateUpdate = await updateAgent(
{ id: agentId },
{ tools: ['listEvents_action_test.com', 'createEvent_action_test.com'] },
{ updatingUserId: authorId.toString(), forceVersion: false },
);
expect(duplicateUpdate.versions).toHaveLength(3); // No new version created
});
test('should handle isDuplicateVersion with arrays containing null/undefined values', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
tools: ['tool1', null, 'tool2', undefined],
});
// Update with same array but different null/undefined arrangement
const updatedAgent = await updateAgent({ id: agentId }, { tools: ['tool1', 'tool2'] });
expect(updatedAgent.versions).toHaveLength(2);
expect(updatedAgent.tools).toEqual(['tool1', 'tool2']);
});
test('should handle isDuplicateVersion with empty objects in tool_kwargs', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
tool_kwargs: [
{ tool: 'tool1', config: { setting: 'value' } },
{},
{ tool: 'tool2', config: {} },
],
});
// Try to update with reordered but equivalent tool_kwargs
const updatedAgent = await updateAgent(
{ id: agentId },
{
tool_kwargs: [
{ tool: 'tool2', config: {} },
{ tool: 'tool1', config: { setting: 'value' } },
{},
],
},
);
// Should create new version as order matters for arrays
expect(updatedAgent.versions).toHaveLength(2);
});
test('should handle isDuplicateVersion with mixed primitive and object arrays', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
mixed_array: [1, 'string', { key: 'value' }, true, null],
});
// Update with same values but different types
const updatedAgent = await updateAgent(
{ id: agentId },
{ mixed_array: ['1', 'string', { key: 'value' }, 'true', null] },
);
// Should create new version as types differ
expect(updatedAgent.versions).toHaveLength(2);
});
test('should handle isDuplicateVersion with deeply nested objects', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const deepObject = {
level1: {
level2: {
level3: {
level4: {
value: 'deep',
array: [1, 2, { nested: true }],
},
},
},
},
};
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
model_parameters: deepObject,
});
// First create a version with changes
await updateAgent({ id: agentId }, { description: 'Updated' });
// Then try to create duplicate of the original version
await updateAgent(
{ id: agentId },
{
model_parameters: deepObject,
description: undefined,
},
);
// Since we're updating back to the same model_parameters but with a different description,
// it should create a new version
const agent = await getAgent({ id: agentId });
expect(agent.versions).toHaveLength(3);
});
test('should handle version comparison with special field types', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
projectIds: [projectId],
model_parameters: { temperature: 0.7 },
});
// Update with a real field change first
const firstUpdate = await updateAgent({ id: agentId }, { description: 'New description' });
expect(firstUpdate.versions).toHaveLength(2);
// Update with model parameters change
const secondUpdate = await updateAgent(
{ id: agentId },
{ model_parameters: { temperature: 0.8 } },
);
expect(secondUpdate.versions).toHaveLength(3);
});
test('should detect changes in support_contact fields', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent with initial support_contact
await createAgent({
id: agentId,
name: 'Agent with Support Contact',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Initial Support',
email: 'initial@support.com',
},
});
// Update support_contact name only
const firstUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Updated Support',
email: 'initial@support.com',
},
},
);
expect(firstUpdate.versions).toHaveLength(2);
expect(firstUpdate.support_contact.name).toBe('Updated Support');
expect(firstUpdate.support_contact.email).toBe('initial@support.com');
// Update support_contact email only
const secondUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Updated Support',
email: 'updated@support.com',
},
},
);
expect(secondUpdate.versions).toHaveLength(3);
expect(secondUpdate.support_contact.email).toBe('updated@support.com');
// Try to update with same support_contact - should be detected as duplicate but return successfully
const duplicateUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Updated Support',
email: 'updated@support.com',
},
},
);
// Should not create a new version
expect(duplicateUpdate.versions).toHaveLength(3);
expect(duplicateUpdate.version).toBe(3);
expect(duplicateUpdate.support_contact.email).toBe('updated@support.com');
});
test('should handle support_contact from empty to populated', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent without support_contact
const agent = await createAgent({
id: agentId,
name: 'Agent without Support',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Verify support_contact is undefined since it wasn't provided
expect(agent.support_contact).toBeUndefined();
// Update to add support_contact
const updated = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'New Support Team',
email: 'support@example.com',
},
},
);
expect(updated.versions).toHaveLength(2);
expect(updated.support_contact.name).toBe('New Support Team');
expect(updated.support_contact.email).toBe('support@example.com');
});
test('should handle support_contact edge cases in isDuplicateVersion', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent with support_contact
await createAgent({
id: agentId,
name: 'Edge Case Agent',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Support',
email: 'support@test.com',
},
});
// Update to empty support_contact
const emptyUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {},
},
);
expect(emptyUpdate.versions).toHaveLength(2);
expect(emptyUpdate.support_contact).toEqual({});
// Update back to populated support_contact
const repopulated = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Support',
email: 'support@test.com',
},
},
);
expect(repopulated.versions).toHaveLength(3);
// Verify all versions have correct support_contact
const finalAgent = await getAgent({ id: agentId });
expect(finalAgent.versions[0].support_contact).toEqual({
name: 'Support',
email: 'support@test.com',
});
expect(finalAgent.versions[1].support_contact).toEqual({});
expect(finalAgent.versions[2].support_contact).toEqual({
name: 'Support',
email: 'support@test.com',
});
});
test('should preserve support_contact in version history', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent
await createAgent({
id: agentId,
name: 'Version History Test',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Initial Contact',
email: 'initial@test.com',
},
});
// Multiple updates with different support_contact values
await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Second Contact',
email: 'second@test.com',
},
},
);
await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Third Contact',
email: 'third@test.com',
},
},
);
const finalAgent = await getAgent({ id: agentId });
// Verify version history
expect(finalAgent.versions).toHaveLength(3);
expect(finalAgent.versions[0].support_contact).toEqual({
name: 'Initial Contact',
email: 'initial@test.com',
});
expect(finalAgent.versions[1].support_contact).toEqual({
name: 'Second Contact',
email: 'second@test.com',
});
expect(finalAgent.versions[2].support_contact).toEqual({
name: 'Third Contact',
email: 'third@test.com',
});
// Current state should match last version
expect(finalAgent.support_contact).toEqual({
name: 'Third Contact',
email: 'third@test.com',
});
});
test('should handle partial support_contact updates', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent with full support_contact
await createAgent({
id: agentId,
name: 'Partial Update Test',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Original Name',
email: 'original@email.com',
},
});
// MongoDB's findOneAndUpdate will replace the entire support_contact object
// So we need to verify that partial updates still work correctly
const updated = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'New Name',
email: '', // Empty email
},
},
);
expect(updated.versions).toHaveLength(2);
expect(updated.support_contact.name).toBe('New Name');
expect(updated.support_contact.email).toBe('');
// Verify isDuplicateVersion works with partial changes - should return successfully without creating new version
const duplicateUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'New Name',
email: '',
},
},
);
// Should not create a new version since content is the same
expect(duplicateUpdate.versions).toHaveLength(2);
expect(duplicateUpdate.version).toBe(2);
expect(duplicateUpdate.support_contact.name).toBe('New Name');
expect(duplicateUpdate.support_contact.email).toBe('');
});
// Edge Cases
describe.each([
{
operation: 'add',
name: 'empty file_id',
needsAgent: true,
params: { tool_resource: 'file_search', file_id: '' },
shouldResolve: true,
},
{
operation: 'add',
name: 'non-existent agent',
needsAgent: false,
params: { tool_resource: 'file_search', file_id: 'file123' },
shouldResolve: false,
error: 'Agent not found for adding resource file',
},
])('addAgentResourceFile with $name', ({ needsAgent, params, shouldResolve, error }) => {
test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
const agent = needsAgent ? await createBasicAgent() : null;
const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
if (shouldResolve) {
await expect(addAgentResourceFile({ agent_id, ...params })).resolves.toBeDefined();
} else {
await expect(addAgentResourceFile({ agent_id, ...params })).rejects.toThrow(error);
}
});
});
describe.each([
{
name: 'empty files array',
files: [],
needsAgent: true,
shouldResolve: true,
},
{
name: 'non-existent tool_resource',
files: [{ tool_resource: 'non_existent_tool', file_id: 'file123' }],
needsAgent: true,
shouldResolve: true,
},
{
name: 'non-existent agent',
files: [{ tool_resource: 'file_search', file_id: 'file123' }],
needsAgent: false,
shouldResolve: false,
error: 'Agent not found for removing resource files',
},
])('removeAgentResourceFiles with $name', ({ files, needsAgent, shouldResolve, error }) => {
test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
const agent = needsAgent ? await createBasicAgent() : null;
const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
if (shouldResolve) {
const result = await removeAgentResourceFiles({ agent_id, files });
expect(result).toBeDefined();
if (agent) {
expect(result.id).toBe(agent.id);
}
} else {
await expect(removeAgentResourceFiles({ agent_id, files })).rejects.toThrow(error);
}
});
});
describe('Edge Cases', () => {
test('should handle extremely large version history', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Version Test',
provider: 'test',
model: 'test-model',
author: authorId,
});
for (let i = 0; i < 20; i++) {
await updateAgent({ id: agentId }, { description: `Version ${i}` });
}
const agent = await getAgent({ id: agentId });
expect(agent.versions).toHaveLength(21);
expect(agent.description).toBe('Version 19');
});
test('should handle revertAgentVersion with invalid version index', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
await expect(revertAgentVersion({ id: agentId }, 5)).rejects.toThrow('Version 5 not found');
});
test('should handle revertAgentVersion with non-existent agent', async () => {
const nonExistentId = `agent_${uuidv4()}`;
await expect(revertAgentVersion({ id: nonExistentId }, 0)).rejects.toThrow(
'Agent not found',
);
});
test('should handle updateAgent with empty update object', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
const updatedAgent = await updateAgent({ id: agentId }, {});
expect(updatedAgent).toBeDefined();
expect(updatedAgent.name).toBe('Test Agent');
expect(updatedAgent.versions).toHaveLength(1);
});
});
});
describe('Action Metadata and Hash Generation', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
test('should generate consistent hash for same action metadata', async () => {
const actionIds = ['test.com_action_123', 'example.com_action_456'];
const actions = [
{
action_id: '123',
metadata: { version: '1.0', endpoints: ['GET /api/test'], schema: { type: 'object' } },
},
{
action_id: '456',
metadata: {
version: '2.0',
endpoints: ['POST /api/example'],
schema: { type: 'string' },
},
},
];
const hash1 = await generateActionMetadataHash(actionIds, actions);
const hash2 = await generateActionMetadataHash(actionIds, actions);
expect(hash1).toBe(hash2);
expect(typeof hash1).toBe('string');
expect(hash1.length).toBe(64); // SHA-256 produces 64 character hex string
});
test('should generate different hashes for different action metadata', async () => {
const actionIds = ['test.com_action_123'];
const actions1 = [
{ action_id: '123', metadata: { version: '1.0', endpoints: ['GET /api/test'] } },
];
const actions2 = [
{ action_id: '123', metadata: { version: '2.0', endpoints: ['GET /api/test'] } },
];
const hash1 = await generateActionMetadataHash(actionIds, actions1);
const hash2 = await generateActionMetadataHash(actionIds, actions2);
expect(hash1).not.toBe(hash2);
});
test('should handle empty action arrays', async () => {
const hash = await generateActionMetadataHash([], []);
expect(hash).toBe('');
});
test('should handle null or undefined action arrays', async () => {
const hash1 = await generateActionMetadataHash(null, []);
const hash2 = await generateActionMetadataHash(undefined, []);
expect(hash1).toBe('');
expect(hash2).toBe('');
});
test('should handle missing action metadata gracefully', async () => {
const actionIds = ['test.com_action_123', 'missing.com_action_999'];
const actions = [
{ action_id: '123', metadata: { version: '1.0' } },
// missing action with id '999'
];
const hash = await generateActionMetadataHash(actionIds, actions);
expect(typeof hash).toBe('string');
expect(hash.length).toBe(64);
});
test('should sort action IDs for consistent hashing', async () => {
const actionIds1 = ['b.com_action_2', 'a.com_action_1'];
const actionIds2 = ['a.com_action_1', 'b.com_action_2'];
const actions = [
{ action_id: '1', metadata: { version: '1.0' } },
{ action_id: '2', metadata: { version: '2.0' } },
];
const hash1 = await generateActionMetadataHash(actionIds1, actions);
const hash2 = await generateActionMetadataHash(actionIds2, actions);
expect(hash1).toBe(hash2);
});
test('should handle complex nested metadata objects', async () => {
const actionIds = ['complex.com_action_1'];
const actions = [
{
action_id: '1',
metadata: {
version: '1.0',
schema: {
type: 'object',
properties: {
name: { type: 'string' },
nested: {
type: 'object',
properties: {
id: { type: 'number' },
tags: { type: 'array', items: { type: 'string' } },
},
},
},
},
endpoints: [
{ path: '/api/test', method: 'GET', params: ['id'] },
{ path: '/api/create', method: 'POST', body: true },
],
},
},
];
const hash = await generateActionMetadataHash(actionIds, actions);
expect(typeof hash).toBe('string');
expect(hash.length).toBe(64);
});
describe('Edge Cases', () => {
test('should handle generateActionMetadataHash with null metadata', async () => {
const hash = await generateActionMetadataHash(
['test.com_action_1'],
[{ action_id: '1', metadata: null }],
);
expect(typeof hash).toBe('string');
});
test('should handle generateActionMetadataHash with deeply nested metadata', async () => {
const deepMetadata = {
level1: {
level2: {
level3: {
level4: {
level5: 'deep value',
array: [1, 2, { nested: true }],
},
},
},
},
};
const hash = await generateActionMetadataHash(
['test.com_action_1'],
[{ action_id: '1', metadata: deepMetadata }],
);
expect(typeof hash).toBe('string');
expect(hash.length).toBe(64);
});
test('should handle generateActionMetadataHash with special characters', async () => {
const specialMetadata = {
unicode: '🚀🎉👍',
symbols: '!@#$%^&*()_+-=[]{}|;:,.<>?',
quotes: 'single\'s and "doubles"',
newlines: 'line1\nline2\r\nline3',
};
const hash = await generateActionMetadataHash(
['test.com_action_1'],
[{ action_id: '1', metadata: specialMetadata }],
);
expect(typeof hash).toBe('string');
expect(hash.length).toBe(64);
});
});
});
describe('Load Agent Functionality', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
test('should return null when agent_id is not provided', async () => {
const mockReq = { user: { id: 'user123' } };
const result = await loadAgent({
req: mockReq,
agent_id: null,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' },
});
expect(result).toBeNull();
});
test('should return null when agent_id is empty string', async () => {
const mockReq = { user: { id: 'user123' } };
const result = await loadAgent({
req: mockReq,
agent_id: '',
endpoint: 'openai',
model_parameters: { model: 'gpt-4' },
});
expect(result).toBeNull();
});
test('should test ephemeral agent loading logic', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({
tool1_mcp_server1: {},
tool2_mcp_server2: {},
another_tool: {},
});
// Mock getMCPServerTools to return tools for each server
getMCPServerTools.mockImplementation(async (_userId, server) => {
if (server === 'server1') {
return { tool1_mcp_server1: {} };
} else if (server === 'server2') {
return { tool2_mcp_server2: {} };
}
return null;
});
const mockReq = {
user: { id: 'user123' },
body: {
promptPrefix: 'Test instructions',
ephemeralAgent: {
execute_code: true,
web_search: true,
mcp: ['server1', 'server2'],
},
},
};
const result = await loadAgent({
req: mockReq,
agent_id: EPHEMERAL_AGENT_ID,
endpoint: 'openai',
model_parameters: { model: 'gpt-4', temperature: 0.7 },
});
if (result) {
// Ephemeral agent ID is encoded with endpoint and model
expect(result.id).toBe('openai__gpt-4');
expect(result.instructions).toBe('Test instructions');
expect(result.provider).toBe('openai');
expect(result.model).toBe('gpt-4');
expect(result.model_parameters.temperature).toBe(0.7);
expect(result.tools).toContain('execute_code');
expect(result.tools).toContain('web_search');
expect(result.tools).toContain('tool1_mcp_server1');
expect(result.tools).toContain('tool2_mcp_server2');
} else {
expect(result).toBeNull();
}
});
test('should return null for non-existent agent', async () => {
const mockReq = { user: { id: 'user123' } };
const result = await loadAgent({
req: mockReq,
agent_id: 'agent_non_existent',
endpoint: 'openai',
model_parameters: { model: 'gpt-4' },
});
expect(result).toBeNull();
});
test('should load agent when user is the author', async () => {
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: userId,
description: 'Test description',
tools: ['web_search'],
});
const mockReq = { user: { id: userId.toString() } };
const result = await loadAgent({
req: mockReq,
agent_id: agentId,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' },
});
expect(result).toBeDefined();
expect(result.id).toBe(agentId);
expect(result.name).toBe('Test Agent');
expect(result.author.toString()).toBe(userId.toString());
expect(result.version).toBe(1);
});
test('should return agent even when user is not author (permissions checked at route level)', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
});
const mockReq = { user: { id: userId.toString() } };
const result = await loadAgent({
req: mockReq,
agent_id: agentId,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' },
});
// With the new permission system, loadAgent returns the agent regardless of permissions
// Permission checks are handled at the route level via middleware
expect(result).toBeTruthy();
expect(result.id).toBe(agentId);
expect(result.name).toBe('Test Agent');
});
test('should handle ephemeral agent with no MCP servers', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({});
const mockReq = {
user: { id: 'user123' },
body: {
promptPrefix: 'Simple instructions',
ephemeralAgent: {
execute_code: false,
web_search: false,
mcp: [],
},
},
};
const result = await loadAgent({
req: mockReq,
agent_id: EPHEMERAL_AGENT_ID,
endpoint: 'openai',
model_parameters: { model: 'gpt-3.5-turbo' },
});
if (result) {
expect(result.tools).toEqual([]);
expect(result.instructions).toBe('Simple instructions');
} else {
expect(result).toBeFalsy();
}
});
test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({});
const mockReq = {
user: { id: 'user123' },
body: {
promptPrefix: 'Basic instructions',
},
};
const result = await loadAgent({
req: mockReq,
agent_id: EPHEMERAL_AGENT_ID,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' },
});
if (result) {
expect(result.tools).toEqual([]);
} else {
expect(result).toBeFalsy();
}
});
describe('Edge Cases', () => {
test('should handle loadAgent with malformed req object', async () => {
const result = await loadAgent({
req: null,
agent_id: 'agent_test',
endpoint: 'openai',
model_parameters: { model: 'gpt-4' },
});
expect(result).toBeNull();
});
test('should handle ephemeral agent with extremely large tool list', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
const largeToolList = Array.from({ length: 100 }, (_, i) => `tool_${i}_mcp_server1`);
const availableTools = largeToolList.reduce((acc, tool) => {
acc[tool] = {};
return acc;
}, {});
getCachedTools.mockResolvedValue(availableTools);
// Mock getMCPServerTools to return all tools for server1
getMCPServerTools.mockImplementation(async (_userId, server) => {
if (server === 'server1') {
return availableTools; // All 100 tools belong to server1
}
return null;
});
const mockReq = {
user: { id: 'user123' },
body: {
promptPrefix: 'Test',
ephemeralAgent: {
execute_code: true,
web_search: true,
mcp: ['server1'],
},
},
};
const result = await loadAgent({
req: mockReq,
agent_id: EPHEMERAL_AGENT_ID,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' },
});
if (result) {
expect(result.tools.length).toBeGreaterThan(100);
}
});
test('should return agent from different project (permissions checked at route level)', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
const projectId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Project Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
projectIds: [projectId],
});
const mockReq = { user: { id: userId.toString() } };
const result = await loadAgent({
req: mockReq,
agent_id: agentId,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' },
});
// With the new permission system, loadAgent returns the agent regardless of permissions
// Permission checks are handled at the route level via middleware
expect(result).toBeTruthy();
expect(result.id).toBe(agentId);
expect(result.name).toBe('Project Agent');
});
});
});
describe('Agent Edge Cases and Error Handling', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
test('should handle agent creation with minimal required fields', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const agent = await createAgent({
id: agentId,
provider: 'test',
model: 'test-model',
author: authorId,
});
expect(agent).toBeDefined();
expect(agent.id).toBe(agentId);
expect(agent.versions).toHaveLength(1);
expect(agent.versions[0].provider).toBe('test');
expect(agent.versions[0].model).toBe('test-model');
});
test('should handle agent creation with all optional fields', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
const agent = await createAgent({
id: agentId,
name: 'Complex Agent',
provider: 'test',
model: 'test-model',
author: authorId,
description: 'Complex description',
instructions: 'Complex instructions',
tools: ['tool1', 'tool2'],
actions: ['action1', 'action2'],
model_parameters: { temperature: 0.8, max_tokens: 1000 },
projectIds: [projectId],
avatar: 'https://example.com/avatar.png',
isCollaborative: true,
tool_resources: {
file_search: { file_ids: ['file1', 'file2'] },
},
});
expect(agent).toBeDefined();
expect(agent.name).toBe('Complex Agent');
expect(agent.description).toBe('Complex description');
expect(agent.instructions).toBe('Complex instructions');
expect(agent.tools).toEqual(['tool1', 'tool2']);
expect(agent.actions).toEqual(['action1', 'action2']);
expect(agent.model_parameters.temperature).toBe(0.8);
expect(agent.model_parameters.max_tokens).toBe(1000);
expect(agent.projectIds.map((id) => id.toString())).toContain(projectId.toString());
expect(agent.avatar).toBe('https://example.com/avatar.png');
expect(agent.isCollaborative).toBe(true);
expect(agent.tool_resources.file_search.file_ids).toEqual(['file1', 'file2']);
});
test('should handle updateAgent with empty update object', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
const updatedAgent = await updateAgent({ id: agentId }, {});
expect(updatedAgent).toBeDefined();
expect(updatedAgent.name).toBe('Test Agent');
expect(updatedAgent.versions).toHaveLength(1); // No new version should be created
});
test('should handle concurrent updates to different agents', async () => {
const agent1Id = `agent_${uuidv4()}`;
const agent2Id = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agent1Id,
name: 'Agent 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: agent2Id,
name: 'Agent 2',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Concurrent updates to different agents
const [updated1, updated2] = await Promise.all([
updateAgent({ id: agent1Id }, { description: 'Updated Agent 1' }),
updateAgent({ id: agent2Id }, { description: 'Updated Agent 2' }),
]);
expect(updated1.description).toBe('Updated Agent 1');
expect(updated2.description).toBe('Updated Agent 2');
expect(updated1.versions).toHaveLength(2);
expect(updated2.versions).toHaveLength(2);
});
test('should handle agent deletion with non-existent ID', async () => {
const nonExistentId = `agent_${uuidv4()}`;
const result = await deleteAgent({ id: nonExistentId });
expect(result).toBeNull();
});
test('should handle updateAgent with MongoDB operators mixed with direct updates', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
tools: ['tool1'],
});
// Test with $push and direct field update
const updatedAgent = await updateAgent(
{ id: agentId },
{
name: 'Updated Name',
$push: { tools: 'tool2' },
},
);
expect(updatedAgent.name).toBe('Updated Name');
expect(updatedAgent.tools).toContain('tool1');
expect(updatedAgent.tools).toContain('tool2');
expect(updatedAgent.versions).toHaveLength(2);
});
test('should handle revertAgentVersion with invalid version index', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Try to revert to non-existent version
await expect(revertAgentVersion({ id: agentId }, 5)).rejects.toThrow('Version 5 not found');
});
test('should handle revertAgentVersion with non-existent agent', async () => {
const nonExistentId = `agent_${uuidv4()}`;
await expect(revertAgentVersion({ id: nonExistentId }, 0)).rejects.toThrow('Agent not found');
});
test('should handle addAgentResourceFile with non-existent agent', async () => {
const nonExistentId = `agent_${uuidv4()}`;
const mockReq = { user: { id: 'user123' } };
await expect(
addAgentResourceFile({
req: mockReq,
agent_id: nonExistentId,
tool_resource: 'file_search',
file_id: 'file123',
}),
).rejects.toThrow('Agent not found for adding resource file');
});
test('should handle removeAgentResourceFiles with non-existent agent', async () => {
const nonExistentId = `agent_${uuidv4()}`;
await expect(
removeAgentResourceFiles({
agent_id: nonExistentId,
files: [{ tool_resource: 'file_search', file_id: 'file123' }],
}),
).rejects.toThrow('Agent not found for removing resource files');
});
test('should handle updateAgent with complex nested updates', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
model_parameters: { temperature: 0.5 },
tools: ['tool1'],
});
// First update with $push operation
const firstUpdate = await updateAgent(
{ id: agentId },
{
$push: { tools: 'tool2' },
},
);
expect(firstUpdate.tools).toContain('tool1');
expect(firstUpdate.tools).toContain('tool2');
// Second update with direct field update and $addToSet
const secondUpdate = await updateAgent(
{ id: agentId },
{
name: 'Updated Agent',
model_parameters: { temperature: 0.8, max_tokens: 500 },
$addToSet: { tools: 'tool3' },
},
);
expect(secondUpdate.name).toBe('Updated Agent');
expect(secondUpdate.model_parameters.temperature).toBe(0.8);
expect(secondUpdate.model_parameters.max_tokens).toBe(500);
expect(secondUpdate.tools).toContain('tool1');
expect(secondUpdate.tools).toContain('tool2');
expect(secondUpdate.tools).toContain('tool3');
expect(secondUpdate.versions).toHaveLength(3);
});
test('should preserve version order in versions array', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Version 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
await updateAgent({ id: agentId }, { name: 'Version 2' });
await updateAgent({ id: agentId }, { name: 'Version 3' });
const finalAgent = await updateAgent({ id: agentId }, { name: 'Version 4' });
expect(finalAgent.versions).toHaveLength(4);
expect(finalAgent.versions[0].name).toBe('Version 1');
expect(finalAgent.versions[1].name).toBe('Version 2');
expect(finalAgent.versions[2].name).toBe('Version 3');
expect(finalAgent.versions[3].name).toBe('Version 4');
expect(finalAgent.name).toBe('Version 4');
});
test('should handle updateAgentProjects error scenarios', async () => {
const nonExistentId = `agent_${uuidv4()}`;
const userId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
// Test with non-existent agent
const result = await updateAgentProjects({
user: { id: userId.toString() },
agentId: nonExistentId,
projectIds: [projectId.toString()],
});
expect(result).toBeNull();
});
test('should handle revertAgentVersion properly', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Original Name',
provider: 'test',
model: 'test-model',
author: authorId,
description: 'Original description',
});
await updateAgent(
{ id: agentId },
{ name: 'Updated Name', description: 'Updated description' },
);
const revertedAgent = await revertAgentVersion({ id: agentId }, 0);
expect(revertedAgent.name).toBe('Original Name');
expect(revertedAgent.description).toBe('Original description');
expect(revertedAgent.author.toString()).toBe(authorId.toString());
});
test('should handle action-related updates with getActions error', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent with actions that might cause getActions to fail
await createAgent({
id: agentId,
name: 'Agent with Actions',
provider: 'test',
model: 'test-model',
author: authorId,
actions: ['test.com_action_invalid_id'],
});
// Update should still work even if getActions fails
const updatedAgent = await updateAgent(
{ id: agentId },
{ description: 'Updated description' },
);
expect(updatedAgent).toBeDefined();
expect(updatedAgent.description).toBe('Updated description');
expect(updatedAgent.versions).toHaveLength(2);
});
test('should handle updateAgent with combined MongoDB operators', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
tools: ['tool1'],
projectIds: [projectId1],
});
// Use multiple operators in single update - but avoid conflicting operations on same field
const updatedAgent = await updateAgent(
{ id: agentId },
{
name: 'Updated Name',
$push: { tools: 'tool2' },
$addToSet: { projectIds: projectId2 },
},
);
const finalAgent = await updateAgent(
{ id: agentId },
{
$pull: { projectIds: projectId1 },
},
);
expect(updatedAgent).toBeDefined();
expect(updatedAgent.name).toBe('Updated Name');
expect(updatedAgent.tools).toContain('tool1');
expect(updatedAgent.tools).toContain('tool2');
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
expect(finalAgent).toBeDefined();
expect(finalAgent.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
expect(finalAgent.versions).toHaveLength(3);
});
test('should handle updateAgent when agent does not exist', async () => {
const nonExistentId = `agent_${uuidv4()}`;
const result = await updateAgent({ id: nonExistentId }, { name: 'New Name' });
expect(result).toBeNull();
});
test('should handle concurrent updates with database errors', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Mock findOneAndUpdate to simulate database error
const cleanup = mockFindOneAndUpdateError(2);
// Concurrent updates where one fails
const promises = [
updateAgent({ id: agentId }, { name: 'Update 1' }),
updateAgent({ id: agentId }, { name: 'Update 2' }),
updateAgent({ id: agentId }, { name: 'Update 3' }),
];
const results = await Promise.allSettled(promises);
cleanup();
const succeeded = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
expect(succeeded).toBe(2);
expect(failed).toBe(1);
});
test('should handle removeAgentResourceFiles when agent is deleted during operation', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
tool_resources: {
file_search: {
file_ids: ['file1', 'file2', 'file3'],
},
},
});
// Mock findOneAndUpdate to return null (simulating deletion)
const originalFindOneAndUpdate = Agent.findOneAndUpdate;
Agent.findOneAndUpdate = jest.fn().mockImplementation(() => ({
lean: jest.fn().mockResolvedValue(null),
}));
// Try to remove files from deleted agent
await expect(
removeAgentResourceFiles({
agent_id: agentId,
files: [
{ tool_resource: 'file_search', file_id: 'file1' },
{ tool_resource: 'file_search', file_id: 'file2' },
],
}),
).rejects.toThrow('Failed to update agent during file removal (pull step)');
Agent.findOneAndUpdate = originalFindOneAndUpdate;
});
test('should handle loadEphemeralAgent with malformed MCP tool names', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({
malformed_tool_name: {}, // No mcp delimiter
tool__server1: {}, // Wrong delimiter
tool_mcp_server1: {}, // Correct format
tool_mcp_server2: {}, // Different server
});
// Mock getMCPServerTools to return only tools matching the server
getMCPServerTools.mockImplementation(async (_userId, server) => {
if (server === 'server1') {
// Only return tool that correctly matches server1 format
return { tool_mcp_server1: {} };
} else if (server === 'server2') {
return { tool_mcp_server2: {} };
}
return null;
});
const mockReq = {
user: { id: 'user123' },
body: {
promptPrefix: 'Test instructions',
ephemeralAgent: {
execute_code: false,
web_search: false,
mcp: ['server1'],
},
},
};
const result = await loadAgent({
req: mockReq,
agent_id: EPHEMERAL_AGENT_ID,
endpoint: 'openai',
model_parameters: { model: 'gpt-4' },
});
if (result) {
expect(result.tools).toEqual(['tool_mcp_server1']);
expect(result.tools).not.toContain('malformed_tool_name');
expect(result.tools).not.toContain('tool__server1');
expect(result.tools).not.toContain('tool_mcp_server2');
}
});
test('should handle addAgentResourceFile when array initialization fails', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Mock the updateOne operation to fail but let updateAgent succeed
const originalUpdateOne = Agent.updateOne;
let updateOneCalled = false;
Agent.updateOne = jest.fn().mockImplementation((...args) => {
if (!updateOneCalled) {
updateOneCalled = true;
return Promise.reject(new Error('Database error'));
}
return originalUpdateOne.apply(Agent, args);
});
try {
const result = await addAgentResourceFile({
agent_id: agentId,
tool_resource: 'new_tool',
file_id: 'file123',
});
expect(result).toBeDefined();
expect(result.tools).toContain('new_tool');
} catch (error) {
expect(error.message).toBe('Database error');
}
Agent.updateOne = originalUpdateOne;
});
});
describe('Agent IDs Field in Version Detection', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
test('should now create new version when agent_ids field changes', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const agent = await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
agent_ids: ['agent1', 'agent2'],
});
expect(agent).toBeDefined();
expect(agent.versions).toHaveLength(1);
const updated = await updateAgent(
{ id: agentId },
{ agent_ids: ['agent1', 'agent2', 'agent3'] },
);
// Since agent_ids is no longer excluded, this should create a new version
expect(updated.versions).toHaveLength(2);
expect(updated.agent_ids).toEqual(['agent1', 'agent2', 'agent3']);
});
test('should detect duplicate version if agent_ids is updated to same value', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
agent_ids: ['agent1', 'agent2'],
});
const updatedAgent = await updateAgent(
{ id: agentId },
{ agent_ids: ['agent1', 'agent2', 'agent3'] },
);
expect(updatedAgent.versions).toHaveLength(2);
// Update with same agent_ids should succeed but not create a new version
const duplicateUpdate = await updateAgent(
{ id: agentId },
{ agent_ids: ['agent1', 'agent2', 'agent3'] },
);
expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
});
test('should handle agent_ids field alongside other fields', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
description: 'Initial description',
agent_ids: ['agent1'],
});
const updated = await updateAgent(
{ id: agentId },
{
agent_ids: ['agent1', 'agent2'],
description: 'Updated description',
},
);
expect(updated.versions).toHaveLength(2);
expect(updated.agent_ids).toEqual(['agent1', 'agent2']);
expect(updated.description).toBe('Updated description');
const updated2 = await updateAgent({ id: agentId }, { description: 'Another description' });
expect(updated2.versions).toHaveLength(3);
expect(updated2.agent_ids).toEqual(['agent1', 'agent2']);
expect(updated2.description).toBe('Another description');
});
test('should skip version creation when skipVersioning option is used', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
// Create agent with initial projectIds
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
projectIds: [projectId1],
});
// Share agent using updateAgentProjects (which uses skipVersioning)
const shared = await updateAgentProjects({
user: { id: authorId.toString() }, // Use the same author ID
agentId: agentId,
projectIds: [projectId2.toString()],
});
// Should NOT create a new version due to skipVersioning
expect(shared.versions).toHaveLength(1);
expect(shared.projectIds.map((id) => id.toString())).toContain(projectId1.toString());
expect(shared.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
// Unshare agent using updateAgentProjects
const unshared = await updateAgentProjects({
user: { id: authorId.toString() },
agentId: agentId,
removeProjectIds: [projectId1.toString()],
});
// Still should NOT create a new version
expect(unshared.versions).toHaveLength(1);
expect(unshared.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
expect(unshared.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
// Regular update without skipVersioning should create a version
const regularUpdate = await updateAgent(
{ id: agentId },
{ description: 'Updated description' },
);
expect(regularUpdate.versions).toHaveLength(2);
expect(regularUpdate.description).toBe('Updated description');
// Direct updateAgent with MongoDB operators should still create versions
const directUpdate = await updateAgent(
{ id: agentId },
{ $addToSet: { projectIds: { $each: [projectId1] } } },
);
expect(directUpdate.versions).toHaveLength(3);
expect(directUpdate.projectIds.length).toBe(2);
});
test('should preserve agent_ids in version history', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
agent_ids: ['agent1'],
});
await updateAgent({ id: agentId }, { agent_ids: ['agent1', 'agent2'] });
await updateAgent({ id: agentId }, { agent_ids: ['agent3'] });
const finalAgent = await getAgent({ id: agentId });
expect(finalAgent.versions).toHaveLength(3);
expect(finalAgent.versions[0].agent_ids).toEqual(['agent1']);
expect(finalAgent.versions[1].agent_ids).toEqual(['agent1', 'agent2']);
expect(finalAgent.versions[2].agent_ids).toEqual(['agent3']);
expect(finalAgent.agent_ids).toEqual(['agent3']);
});
test('should handle empty agent_ids arrays', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
agent_ids: ['agent1', 'agent2'],
});
const updated = await updateAgent({ id: agentId }, { agent_ids: [] });
expect(updated.versions).toHaveLength(2);
expect(updated.agent_ids).toEqual([]);
// Update with same empty agent_ids should succeed but not create a new version
const duplicateUpdate = await updateAgent({ id: agentId }, { agent_ids: [] });
expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
expect(duplicateUpdate.agent_ids).toEqual([]);
});
test('should handle agent without agent_ids field', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const agent = await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
expect(agent.agent_ids).toEqual([]);
const updated = await updateAgent({ id: agentId }, { agent_ids: ['agent1'] });
expect(updated.versions).toHaveLength(2);
expect(updated.agent_ids).toEqual(['agent1']);
});
});
});
describe('Support Contact Field', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
it('should not create subdocument with ObjectId for support_contact', async () => {
const userId = new mongoose.Types.ObjectId();
const agentData = {
id: 'agent_test_support',
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: userId,
support_contact: {
name: 'Support Team',
email: 'support@example.com',
},
};
// Create agent
const agent = await createAgent(agentData);
// Verify support_contact is stored correctly
expect(agent.support_contact).toBeDefined();
expect(agent.support_contact.name).toBe('Support Team');
expect(agent.support_contact.email).toBe('support@example.com');
// Verify no _id field is created in support_contact
expect(agent.support_contact._id).toBeUndefined();
// Fetch from database to double-check
const dbAgent = await Agent.findOne({ id: agentData.id });
expect(dbAgent.support_contact).toBeDefined();
expect(dbAgent.support_contact.name).toBe('Support Team');
expect(dbAgent.support_contact.email).toBe('support@example.com');
expect(dbAgent.support_contact._id).toBeUndefined();
});
it('should handle empty support_contact correctly', async () => {
const userId = new mongoose.Types.ObjectId();
const agentData = {
id: 'agent_test_empty_support',
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: userId,
support_contact: {},
};
const agent = await createAgent(agentData);
// Verify empty support_contact is stored as empty object
expect(agent.support_contact).toEqual({});
expect(agent.support_contact._id).toBeUndefined();
});
it('should handle missing support_contact correctly', async () => {
const userId = new mongoose.Types.ObjectId();
const agentData = {
id: 'agent_test_no_support',
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: userId,
};
const agent = await createAgent(agentData);
// Verify support_contact is undefined when not provided
expect(agent.support_contact).toBeUndefined();
});
describe('getListAgentsByAccess - Security Tests', () => {
let userA, userB;
let agentA1, agentA2, agentA3;
beforeEach(async () => {
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await Agent.deleteMany({});
await AclEntry.deleteMany({});
// Create two users
userA = new mongoose.Types.ObjectId();
userB = new mongoose.Types.ObjectId();
// Create agents for user A
agentA1 = await createAgent({
id: `agent_${uuidv4().slice(0, 12)}`,
name: 'Agent A1',
description: 'User A agent 1',
provider: 'openai',
model: 'gpt-4',
author: userA,
});
agentA2 = await createAgent({
id: `agent_${uuidv4().slice(0, 12)}`,
name: 'Agent A2',
description: 'User A agent 2',
provider: 'openai',
model: 'gpt-4',
author: userA,
});
agentA3 = await createAgent({
id: `agent_${uuidv4().slice(0, 12)}`,
name: 'Agent A3',
description: 'User A agent 3',
provider: 'openai',
model: 'gpt-4',
author: userA,
});
});
test('should return empty list when user has no accessible agents (empty accessibleIds)', async () => {
// User B has no agents and no shared agents
const result = await getListAgentsByAccess({
accessibleIds: [],
otherParams: {},
});
expect(result.data).toHaveLength(0);
expect(result.has_more).toBe(false);
expect(result.first_id).toBeNull();
expect(result.last_id).toBeNull();
});
test('should not return other users agents when accessibleIds is empty', async () => {
// User B trying to list agents with empty accessibleIds should not see User A's agents
const result = await getListAgentsByAccess({
accessibleIds: [],
otherParams: { author: userB },
});
expect(result.data).toHaveLength(0);
expect(result.has_more).toBe(false);
});
test('should only return agents in accessibleIds list', async () => {
// Give User B access to only one of User A's agents
const accessibleIds = [agentA1._id];
const result = await getListAgentsByAccess({
accessibleIds,
otherParams: {},
});
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(agentA1.id);
expect(result.data[0].name).toBe('Agent A1');
});
test('should return multiple accessible agents when provided', async () => {
// Give User B access to two of User A's agents
const accessibleIds = [agentA1._id, agentA3._id];
const result = await getListAgentsByAccess({
accessibleIds,
otherParams: {},
});
expect(result.data).toHaveLength(2);
const returnedIds = result.data.map((agent) => agent.id);
expect(returnedIds).toContain(agentA1.id);
expect(returnedIds).toContain(agentA3.id);
expect(returnedIds).not.toContain(agentA2.id);
});
test('should respect other query parameters while enforcing accessibleIds', async () => {
// Give access to all agents but filter by name
const accessibleIds = [agentA1._id, agentA2._id, agentA3._id];
const result = await getListAgentsByAccess({
accessibleIds,
otherParams: { name: 'Agent A2' },
});
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(agentA2.id);
});
test('should handle pagination correctly with accessibleIds filter', async () => {
// Create more agents
const moreAgents = [];
for (let i = 4; i <= 10; i++) {
const agent = await createAgent({
id: `agent_${uuidv4().slice(0, 12)}`,
name: `Agent A${i}`,
description: `User A agent ${i}`,
provider: 'openai',
model: 'gpt-4',
author: userA,
});
moreAgents.push(agent);
}
// Give access to all agents
const allAgentIds = [agentA1, agentA2, agentA3, ...moreAgents].map((a) => a._id);
// First page
const page1 = await getListAgentsByAccess({
accessibleIds: allAgentIds,
otherParams: {},
limit: 5,
});
expect(page1.data).toHaveLength(5);
expect(page1.has_more).toBe(true);
expect(page1.after).toBeTruthy();
// Second page
const page2 = await getListAgentsByAccess({
accessibleIds: allAgentIds,
otherParams: {},
limit: 5,
after: page1.after,
});
expect(page2.data).toHaveLength(5);
expect(page2.has_more).toBe(false);
// Verify no overlap between pages
const page1Ids = page1.data.map((a) => a.id);
const page2Ids = page2.data.map((a) => a.id);
const intersection = page1Ids.filter((id) => page2Ids.includes(id));
expect(intersection).toHaveLength(0);
});
test('should return empty list when accessibleIds contains non-existent IDs', async () => {
// Try with non-existent agent IDs
const fakeIds = [new mongoose.Types.ObjectId(), new mongoose.Types.ObjectId()];
const result = await getListAgentsByAccess({
accessibleIds: fakeIds,
otherParams: {},
});
expect(result.data).toHaveLength(0);
expect(result.has_more).toBe(false);
});
test('should handle undefined accessibleIds as empty array', async () => {
// When accessibleIds is undefined, it should be treated as empty array
const result = await getListAgentsByAccess({
accessibleIds: undefined,
otherParams: {},
});
expect(result.data).toHaveLength(0);
expect(result.has_more).toBe(false);
});
test('should combine accessibleIds with author filter correctly', async () => {
// Create an agent for User B
const agentB1 = await createAgent({
id: `agent_${uuidv4().slice(0, 12)}`,
name: 'Agent B1',
description: 'User B agent 1',
provider: 'openai',
model: 'gpt-4',
author: userB,
});
// Give User B access to one of User A's agents
const accessibleIds = [agentA1._id, agentB1._id];
// Filter by author should further restrict the results
const result = await getListAgentsByAccess({
accessibleIds,
otherParams: { author: userB },
});
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(agentB1.id);
expect(result.data[0].author).toBe(userB.toString());
});
});
});
function createBasicAgent(overrides = {}) {
const defaults = {
id: `agent_${uuidv4()}`,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: new mongoose.Types.ObjectId(),
};
return createAgent({ ...defaults, ...overrides });
}
function createTestIds() {
return {
agentId: `agent_${uuidv4()}`,
authorId: new mongoose.Types.ObjectId(),
projectId: new mongoose.Types.ObjectId(),
fileId: uuidv4(),
};
}
function createFileOperations(agentId, fileIds, operation = 'add') {
return fileIds.map((fileId) =>
operation === 'add'
? addAgentResourceFile({ agent_id: agentId, tool_resource: 'test_tool', file_id: fileId })
: removeAgentResourceFiles({
agent_id: agentId,
files: [{ tool_resource: 'test_tool', file_id: fileId }],
}),
);
}
function mockFindOneAndUpdateError(errorOnCall = 1) {
const original = Agent.findOneAndUpdate;
let callCount = 0;
Agent.findOneAndUpdate = jest.fn().mockImplementation((...args) => {
callCount++;
if (callCount === errorOnCall) {
throw new Error('Database connection lost');
}
return original.apply(Agent, args);
});
return () => {
Agent.findOneAndUpdate = original;
};
}
function generateVersionTestCases() {
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
return [
{
name: 'simple field update',
initial: {
name: 'Test Agent',
description: 'Initial description',
},
update: { name: 'Updated Name' },
duplicate: { name: 'Updated Name' },
},
{
name: 'object field update',
initial: {
model_parameters: { temperature: 0.7 },
},
update: { model_parameters: { temperature: 0.8 } },
duplicate: { model_parameters: { temperature: 0.8 } },
},
{
name: 'array field update',
initial: {
tools: ['tool1', 'tool2'],
},
update: { tools: ['tool2', 'tool3'] },
duplicate: { tools: ['tool2', 'tool3'] },
},
{
name: 'projectIds update',
initial: {
projectIds: [projectId1],
},
update: { projectIds: [projectId1, projectId2] },
duplicate: { projectIds: [projectId2, projectId1] },
},
];
}