mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 06:17:21 +02:00
* fix: wrap seedDatabase() in runAsSystem() for strict tenant mode
seedDatabase() was called without tenant context at startup, causing
every Mongoose operation inside it to throw when
TENANT_ISOLATION_STRICT=true. Wrapping in runAsSystem() gives it the
SYSTEM_TENANT_ID sentinel so the isolation plugin skips filtering,
matching the pattern already used for performStartupChecks and
updateInterfacePermissions.
* fix: chain tenantContextMiddleware in optionalJwtAuth
optionalJwtAuth populated req.user but never established ALS tenant
context, unlike requireJwtAuth which chains tenantContextMiddleware
after successful auth. Authenticated users hitting routes with
optionalJwtAuth (e.g. /api/banner) had no tenant isolation.
* feat: tenant-safe bulkWrite wrapper and call-site migration
Mongoose's bulkWrite() does not trigger schema-level middleware hooks,
so the applyTenantIsolation plugin cannot intercept it. This adds a
tenantSafeBulkWrite() utility that injects the current ALS tenant
context into every operation's filter/document before delegating to
native bulkWrite.
Migrates all 8 runtime bulkWrite call sites:
- agentCategory (seedCategories, ensureDefaultCategories)
- conversation (bulkSaveConvos)
- message (bulkSaveMessages)
- file (batchUpdateFiles)
- conversationTag (updateTagsForConversation, bulkIncrementTagCounts)
- aclEntry (bulkWriteAclEntries)
systemGrant.seedSystemGrants is intentionally not migrated — it uses
explicit tenantId: { $exists: false } filters and is exempt from the
isolation plugin.
* feat: pre-auth tenant middleware and tenant-scoped config cache
Adds preAuthTenantMiddleware that reads X-Tenant-Id from the request
header and wraps downstream in tenantStorage ALS context. Wired onto
/oauth, /api/auth, /api/config, and /api/share — unauthenticated
routes that need tenant scoping before JWT auth runs.
The /api/config cache key is now tenant-scoped
(STARTUP_CONFIG:${tenantId}) so multi-tenant deployments serve the
correct login page config per tenant.
The middleware is intentionally minimal — no subdomain parsing, no
OIDC claim extraction. The private fork's reverse proxy or auth
gateway sets the header.
* feat: accept optional tenantId in updateInterfacePermissions
When tenantId is provided, the function re-enters inside
tenantStorage.run({ tenantId }) so all downstream Mongoose queries
target that tenant's roles instead of the system context. This lets
the private fork's tenant provisioning flow call
updateInterfacePermissions per-tenant after creating tenant-scoped
ADMIN/USER roles.
* fix: tenant-filter $lookup in getPromptGroup aggregation
The $lookup stage in getPromptGroup() queried the prompts collection
without tenant filtering. While the outer PromptGroup aggregate is
protected by the tenantIsolation plugin's pre('aggregate') hook,
$lookup runs as an internal MongoDB operation that bypasses Mongoose
hooks entirely.
Converts from simple field-based $lookup to pipeline-based $lookup
with an explicit tenantId match when tenant context is active.
* fix: replace field-level unique indexes with tenant-scoped compounds
Field-level unique:true creates a globally-unique single-field index in
MongoDB, which would cause insert failures across tenants sharing the
same ID values.
- agent.id: removed field-level unique, added { id, tenantId } compound
- convo.conversationId: removed field-level unique (compound at line 50
already exists: { conversationId, user, tenantId })
- message.messageId: removed field-level unique (compound at line 165
already exists: { messageId, user, tenantId })
- preset.presetId: removed field-level unique, added { presetId, tenantId }
compound
* fix: scope MODELS_CONFIG, ENDPOINT_CONFIG, PLUGINS, TOOLS caches by tenant
These caches store per-tenant configuration (available models, endpoint
settings, plugin availability, tool definitions) but were using global
cache keys. In multi-tenant mode, one tenant's cached config would be
served to all tenants.
Appends :${tenantId} to cache keys when tenant context is active.
Falls back to the unscoped key when no tenant context exists (backward
compatible for single-tenant OSS deployments).
Covers all read, write, and delete sites:
- ModelController.js: get/set MODELS_CONFIG
- PluginController.js: get/set PLUGINS, get/set TOOLS
- getEndpointsConfig.js: get/set/delete ENDPOINT_CONFIG
- app.js: delete ENDPOINT_CONFIG (clearEndpointConfigCache)
- mcp.js: delete TOOLS (updateMCPTools, mergeAppTools)
- importers.js: get ENDPOINT_CONFIG
* fix: add getTenantId to PluginController spec mock
The data-schemas mock was missing getTenantId, causing all
PluginController tests to throw when the controller calls
getTenantId() for tenant-scoped cache keys.
* fix: address review findings — migration, strict-mode, DRY, types
Addresses all CRITICAL, MAJOR, and MINOR review findings:
F1 (CRITICAL): Add agents, conversations, messages, presets to
SUPERSEDED_INDEXES in tenantIndexes.ts so dropSupersededTenantIndexes()
drops the old single-field unique indexes that block multi-tenant inserts.
F2 (CRITICAL): Unknown bulkWrite op types now throw in strict mode
instead of silently passing through without tenant injection.
F3 (MAJOR): Replace wildcard export with named export for
tenantSafeBulkWrite, hiding _resetBulkWriteStrictCache from the
public package API.
F5 (MAJOR): Restore AnyBulkWriteOperation<IAclEntry>[] typing on
bulkWriteAclEntries — the unparameterized wrapper accepts parameterized
ops as a subtype.
F7 (MAJOR): Fix config.js tenant precedence — JWT-derived
req.user.tenantId now takes priority over the X-Tenant-Id header for
authenticated requests.
F8 (MINOR): Extract scopedCacheKey() helper into tenantContext.ts and
replace all 11 inline occurrences across 7 files.
F9 (MINOR): Use simple localField/foreignField $lookup for the
non-tenant getPromptGroup path (more efficient index seeks).
F12 (NIT): Remove redundant BulkOp type alias.
F13 (NIT): Remove debug log that leaked raw tenantId.
* fix: add new superseded indexes to tenantIndexes test fixture
The test creates old indexes to verify the migration drops them.
Missing fixture entries for agents.id_1, conversations.conversationId_1,
messages.messageId_1, and presets.presetId_1 caused the count assertion
to fail (expected 22, got 18).
* fix: restore logger.warn for unknown bulk op types in non-strict mode
* fix: block SYSTEM_TENANT_ID sentinel from external header input
CRITICAL: preAuthTenantMiddleware accepted any string as X-Tenant-Id,
including '__SYSTEM__'. The tenantIsolation plugin treats SYSTEM_TENANT_ID
as an explicit bypass — skipping ALL query filters. A client sending
X-Tenant-Id: __SYSTEM__ to pre-auth routes (/api/share, /api/config,
/api/auth, /oauth) would execute Mongoose operations without tenant
isolation.
Fixes:
- preAuthTenantMiddleware rejects SYSTEM_TENANT_ID in header
- scopedCacheKey returns the base key (not key:__SYSTEM__) in system
context, preventing stale cache entries during runAsSystem()
- updateInterfacePermissions guards tenantId against SYSTEM_TENANT_ID
- $lookup pipeline separates $expr join from constant tenantId match
for better index utilization
- Regression test for sentinel rejection in preAuthTenant.spec.ts
- Remove redundant getTenantId() call in config.js
* test: add missing deleteMany/replaceOne coverage, fix vacuous ALS assertions
bulkWrite spec:
- deleteMany: verifies tenant-scoped deletion leaves other tenants untouched
- replaceOne: verifies tenantId injected into both filter and replacement
- replaceOne overwrite: verifies a conflicting tenantId in the replacement
document is overwritten by the ALS tenant (defense-in-depth)
- empty ops array: verifies graceful handling
preAuthTenant spec:
- All negative-case tests now use the capturedNext pattern to verify
getTenantId() inside the middleware's execution context, not the
test runner's outer frame (which was always undefined regardless)
* feat: tenant-isolate MESSAGES cache, FLOWS cache, and GenerationJobManager
MESSAGES cache (streamAudio.js):
- Cache key now uses scopedCacheKey(messageId) to prefix with tenantId,
preventing cross-tenant message content reads during TTS streaming.
FLOWS cache (FlowStateManager):
- getFlowKey() now generates ${type}:${tenantId}:${flowId} when tenant
context is active, isolating OAuth flow state per tenant.
GenerationJobManager:
- tenantId added to SerializableJobData and GenerationJobMetadata
- createJob() captures the current ALS tenant context (excluding
SYSTEM_TENANT_ID) and stores it in job metadata
- SSE subscription endpoint validates job.metadata.tenantId matches
req.user.tenantId, blocking cross-tenant stream access
- Both InMemoryJobStore and RedisJobStore updated to accept tenantId
* fix: add getTenantId and SYSTEM_TENANT_ID to MCP OAuth test mocks
FlowStateManager.getFlowKey() now calls getTenantId() for tenant-scoped
flow keys. The 4 MCP OAuth test files mock @librechat/data-schemas
without these exports, causing TypeError at runtime.
* fix: correct import ordering per AGENTS.md conventions
Package imports sorted shortest to longest line length, local imports
sorted longest to shortest — fixes ordering violations introduced by
our new imports across 8 files.
* fix: deserialize tenantId in RedisJobStore — cross-tenant SSE guard was no-op in Redis mode
serializeJob() writes tenantId to the Redis hash via Object.entries,
but deserializeJob() manually enumerates fields and omitted tenantId.
Every getJob() from Redis returned tenantId: undefined, causing the
SSE route's cross-tenant guard to short-circuit (undefined && ... → false).
* test: SSE tenant guard, FlowStateManager key consistency, ALS scope docs
SSE stream tenant tests (streamTenant.spec.js):
- Cross-tenant user accessing another tenant's stream → 403
- Same-tenant user accessing own stream → allowed
- OSS mode (no tenantId on job) → tenant check skipped
FlowStateManager tenant tests (manager.tenant.spec.ts):
- completeFlow finds flow created under same tenant context
- completeFlow does NOT find flow under different tenant context
- Unscoped flows are separate from tenant-scoped flows
Documentation:
- JSDoc on getFlowKey documenting ALS context consistency requirement
- Comment on streamAudio.js scopedCacheKey capture site
* fix: SSE stream tests hang on success path, remove internal fork references
The success-path tests entered the SSE streaming code which never
closes, causing timeout. Mock subscribe() to end the response
immediately. Restructured assertions to verify non-403/non-404.
Removed "private fork" and "OSS" references from code and test
descriptions — replaced with "deployment layer", "multi-tenant
deployments", and "single-tenant mode".
* fix: address review findings — test rigor, tenant ID validation, docs
F1: SSE stream tests now mock subscribe() with correct signature
(streamId, writeEvent, onDone, onError) and assert 200 status,
verifying the tenant guard actually allows through same-tenant users.
F2: completeFlow logs the attempted key and ALS tenantId when flow
is not found, so reverse proxy misconfiguration (missing X-Tenant-Id
on OAuth callback) produces an actionable warning.
F3/F10: preAuthTenantMiddleware validates tenant ID format — rejects
colons, special characters, and values exceeding 128 chars. Trims
whitespace. Prevents cache key collisions via crafted headers.
F4: Documented cache invalidation scope limitation in
clearEndpointConfigCache — only the calling tenant's key is cleared;
other tenants expire via TTL.
F7: getFlowKey JSDoc now lists all 8 methods requiring consistent
ALS context.
F8: Added dedicated scopedCacheKey unit tests — base key without
context, base key in system context, scoped key with tenant, no
ALS leakage across scope boundaries.
* fix: revert flow key tenant scoping, fix SSE test timing
FlowStateManager: Reverts tenant-scoped flow keys. OAuth callbacks
arrive without tenant ALS context (provider redirects don't carry
X-Tenant-Id), so completeFlow/failFlow would never find flows
created under tenant context. Flow IDs are random UUIDs with no
collision risk, and flow data is ephemeral (TTL-bounded).
SSE tests: Use process.nextTick for onDone callback so Express
response headers are flushed before res.write/res.end are called.
* fix: restore getTenantId import for completeFlow diagnostic log
* fix: correct completeFlow warning message, add missing flow test
The warning referenced X-Tenant-Id header consistency which was only
relevant when flow keys were tenant-scoped (since reverted). Updated
to list actual causes: TTL expiry, missing flow, or routing to a
different instance without shared Keyv storage.
Removed the getTenantId() call and import — no longer needed since
flow keys are unscoped.
Added test for the !flowState branch in completeFlow — verifies
return false and logger.warn on nonexistent flow ID.
* fix: add explicit return type to recursive updateInterfacePermissions
The recursive call (tenantId branch calls itself without tenantId)
causes TypeScript to infer circular return type 'any'. Adding
explicit Promise<void> satisfies the rollup typescript plugin.
* fix: update MCPOAuthRaceCondition test to match new completeFlow warning
* fix: clearEndpointConfigCache deletes both scoped and unscoped keys
Unauthenticated /api/endpoints requests populate the unscoped
ENDPOINT_CONFIG key. Admin config mutations clear only the
tenant-scoped key, leaving the unscoped entry stale indefinitely.
Now deletes both when in tenant context.
* fix: tenant guard on abort/status endpoints, warn logs, test coverage
F1: Add tenant guard to /chat/status/:conversationId and /chat/abort
matching the existing guard on /chat/stream/:streamId. The status
endpoint exposes aggregatedContent (AI response text) which requires
tenant-level access control.
F2: preAuthTenantMiddleware now logs warn for rejected __SYSTEM__
sentinel and malformed tenant IDs, providing observability for
bypass probing attempts.
F3: Abort fallback path (getActiveJobIdsForUser) now has tenant
check after resolving the job.
F4: Test for strict mode + SYSTEM_TENANT_ID — verifies runAsSystem
bypasses tenantSafeBulkWrite without throwing in strict mode.
F5: Test for job with tenantId + user without tenantId → 403.
F10: Regex uses idiomatic hyphen-at-start form.
F11: Test descriptions changed from "rejects" to "ignores" since
middleware calls next() (not 4xx).
Also fixes MCPOAuthRaceCondition test assertion to match updated
completeFlow warning message.
* fix: test coverage for logger.warn, status/abort guards, consistency
A: preAuthTenant spec now mocks logger and asserts warn calls for
__SYSTEM__ sentinel, malformed characters, and oversized headers.
B: streamTenant spec expanded with status and abort endpoint tests —
cross-tenant status returns 403, same-tenant returns 200 with body,
cross-tenant abort returns 403.
C: Abort endpoint uses req.user.tenantId (not req.user?.tenantId)
matching stream/status pattern — requireJwtAuth guarantees req.user.
D: Malformed header warning now includes ip in log metadata,
matching the sentinel warning for consistent SOC correlation.
* fix: assert ip field in malformed header warn tests
* fix: parallelize cache deletes, document tenant guard, fix import order
- clearEndpointConfigCache uses Promise.all for independent cache
deletes instead of sequential awaits
- SSE stream tenant guard has inline comment explaining backward-compat
behavior for untenanted legacy jobs
- conversation.ts local imports reordered longest-to-shortest per
AGENTS.md
* fix: tenant-qualify userJobs keys, document tenant guard backward-compat
Job store userJobs keys now include tenantId when available:
- Redis: stream:user:{tenantId:userId}:jobs (falls back to
stream:user:{userId}:jobs when no tenant)
- InMemory: composite key tenantId:userId in userJobMap
getActiveJobIdsByUser/getActiveJobIdsForUser accept optional tenantId
parameter, threaded through from req.user.tenantId at all call sites
(/chat/active and /chat/abort fallback).
Added inline comments on all three SSE tenant guards explaining the
backward-compat design: untenanted legacy jobs remain accessible
when the userId check passes.
* fix: parallelize cache deletes, document tenant guard, fix import order
Fix InMemoryJobStore.getActiveJobIdsByUser empty-set cleanup to use
the tenant-qualified userKey instead of bare userId — prevents
orphaned empty Sets accumulating in userJobMap for multi-tenant users.
Document cross-tenant staleness in clearEndpointConfigCache JSDoc —
other tenants' scoped keys expire via TTL, not active invalidation.
* fix: cleanup userJobMap leak, startup warning, DRY tenant guard, docs
F1: InMemoryJobStore.cleanup() now removes entries from userJobMap
before calling deleteJob, preventing orphaned empty Sets from
accumulating with tenant-qualified composite keys.
F2: Startup warning when TENANT_ISOLATION_STRICT is active — reminds
operators to configure reverse proxy to control X-Tenant-Id header.
F3: mergeAppTools JSDoc documents that tenant-scoped TOOLS keys are
not actively invalidated (matching clearEndpointConfigCache pattern).
F5: Abort handler getActiveJobIdsForUser call uses req.user.tenantId
(not req.user?.tenantId) — consistent with stream/status handlers.
F6: updateInterfacePermissions JSDoc clarifies SYSTEM_TENANT_ID
behavior — falls through to caller's ALS context.
F7: Extracted hasTenantMismatch() helper, replacing three identical
inline tenant guard blocks across stream/status/abort endpoints.
F9: scopedCacheKey JSDoc documents both passthrough cases (no context
and SYSTEM_TENANT_ID context).
* fix: clean userJobMap in evictOldest — same leak as cleanup()
517 lines
17 KiB
JavaScript
517 lines
17 KiB
JavaScript
const { CacheKeys } = require('librechat-data-provider');
|
|
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
|
|
const { getLogStores } = require('~/cache');
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
debug: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
},
|
|
scopedCacheKey: jest.fn((key) => key),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Config', () => ({
|
|
getCachedTools: jest.fn(),
|
|
getAppConfig: jest.fn().mockResolvedValue({
|
|
filteredTools: [],
|
|
includedTools: [],
|
|
}),
|
|
setCachedTools: jest.fn(),
|
|
}));
|
|
|
|
// loadAndFormatTools mock removed - no longer used in PluginController
|
|
// getMCPManager mock removed - no longer used in PluginController
|
|
|
|
jest.mock('~/app/clients/tools', () => ({
|
|
availableTools: [],
|
|
toolkits: [],
|
|
}));
|
|
|
|
jest.mock('~/cache', () => ({
|
|
getLogStores: jest.fn(),
|
|
}));
|
|
|
|
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
|
|
|
|
describe('PluginController', () => {
|
|
let mockReq, mockRes, mockCache;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockReq = {
|
|
user: { id: 'test-user-id' },
|
|
config: {
|
|
filteredTools: [],
|
|
includedTools: [],
|
|
},
|
|
};
|
|
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
|
mockCache = { get: jest.fn(), set: jest.fn() };
|
|
getLogStores.mockReturnValue(mockCache);
|
|
|
|
// Clear availableTools and toolkits arrays before each test
|
|
require('~/app/clients/tools').availableTools.length = 0;
|
|
require('~/app/clients/tools').toolkits.length = 0;
|
|
|
|
// Reset getCachedTools mock to ensure clean state
|
|
getCachedTools.mockReset();
|
|
|
|
// Reset getAppConfig mock to ensure clean state with default values
|
|
getAppConfig.mockReset();
|
|
getAppConfig.mockResolvedValue({
|
|
filteredTools: [],
|
|
includedTools: [],
|
|
});
|
|
});
|
|
|
|
describe('cache namespace', () => {
|
|
it('getAvailablePluginsController should use TOOL_CACHE namespace', async () => {
|
|
mockCache.get.mockResolvedValue([]);
|
|
await getAvailablePluginsController(mockReq, mockRes);
|
|
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
|
|
});
|
|
|
|
it('getAvailableTools should use TOOL_CACHE namespace', async () => {
|
|
mockCache.get.mockResolvedValue([]);
|
|
await getAvailableTools(mockReq, mockRes);
|
|
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
|
|
});
|
|
|
|
it('should NOT use CONFIG_STORE namespace for tool/plugin operations', async () => {
|
|
mockCache.get.mockResolvedValue([]);
|
|
await getAvailablePluginsController(mockReq, mockRes);
|
|
await getAvailableTools(mockReq, mockRes);
|
|
const allCalls = getLogStores.mock.calls.flat();
|
|
expect(allCalls).not.toContain(CacheKeys.CONFIG_STORE);
|
|
});
|
|
});
|
|
|
|
describe('getAvailablePluginsController', () => {
|
|
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
|
|
// Add plugins with duplicates to availableTools
|
|
const mockPlugins = [
|
|
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
|
|
{ name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' },
|
|
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
|
];
|
|
|
|
require('~/app/clients/tools').availableTools.push(...mockPlugins);
|
|
|
|
mockCache.get.mockResolvedValue(null);
|
|
|
|
// Configure getAppConfig to return the expected config
|
|
getAppConfig.mockResolvedValueOnce({
|
|
filteredTools: [],
|
|
includedTools: [],
|
|
});
|
|
|
|
await getAvailablePluginsController(mockReq, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
const responseData = mockRes.json.mock.calls[0][0];
|
|
// The real filterUniquePlugins should have removed the duplicate
|
|
expect(responseData).toHaveLength(2);
|
|
expect(responseData[0].pluginKey).toBe('key1');
|
|
expect(responseData[1].pluginKey).toBe('key2');
|
|
});
|
|
|
|
it('should use checkPluginAuth to verify plugin authentication', async () => {
|
|
// checkPluginAuth returns false for plugins without authConfig
|
|
// so authenticated property won't be added
|
|
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
|
|
|
|
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
|
mockCache.get.mockResolvedValue(null);
|
|
|
|
// Configure getAppConfig to return the expected config
|
|
getAppConfig.mockResolvedValueOnce({
|
|
filteredTools: [],
|
|
includedTools: [],
|
|
});
|
|
|
|
await getAvailablePluginsController(mockReq, mockRes);
|
|
|
|
const responseData = mockRes.json.mock.calls[0][0];
|
|
// The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added
|
|
expect(responseData[0].authenticated).toBeUndefined();
|
|
});
|
|
|
|
it('should return cached plugins when available', async () => {
|
|
const cachedPlugins = [
|
|
{ name: 'CachedPlugin', pluginKey: 'cached', description: 'Cached plugin' },
|
|
];
|
|
|
|
mockCache.get.mockResolvedValue(cachedPlugins);
|
|
|
|
await getAvailablePluginsController(mockReq, mockRes);
|
|
|
|
// When cache is hit, we return immediately without processing
|
|
expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
|
|
});
|
|
|
|
it('should filter plugins based on includedTools', async () => {
|
|
const mockPlugins = [
|
|
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
|
|
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
|
];
|
|
|
|
require('~/app/clients/tools').availableTools.push(...mockPlugins);
|
|
mockCache.get.mockResolvedValue(null);
|
|
|
|
// Configure getAppConfig to return config with includedTools
|
|
getAppConfig.mockResolvedValueOnce({
|
|
filteredTools: [],
|
|
includedTools: ['key1'],
|
|
});
|
|
|
|
await getAvailablePluginsController(mockReq, mockRes);
|
|
|
|
const responseData = mockRes.json.mock.calls[0][0];
|
|
expect(responseData).toHaveLength(1);
|
|
expect(responseData[0].pluginKey).toBe('key1');
|
|
});
|
|
});
|
|
|
|
describe('getAvailableTools', () => {
|
|
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
|
|
const mockUserTools = {
|
|
'user-tool': {
|
|
type: 'function',
|
|
function: {
|
|
name: 'user-tool',
|
|
description: 'User tool',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
};
|
|
|
|
const mockCachedPlugins = [
|
|
{ name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' },
|
|
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
|
|
];
|
|
|
|
mockCache.get.mockResolvedValue(mockCachedPlugins);
|
|
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
|
mockReq.config = {
|
|
mcpConfig: null,
|
|
paths: { structuredTools: '/mock/path' },
|
|
};
|
|
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
const responseData = mockRes.json.mock.calls[0][0];
|
|
expect(Array.isArray(responseData)).toBe(true);
|
|
// The real filterUniquePlugins should have deduplicated tools with same pluginKey
|
|
const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
|
|
expect(userToolCount).toBe(1);
|
|
});
|
|
|
|
it('should use checkPluginAuth to verify authentication status', async () => {
|
|
// Add a plugin to availableTools that will be checked
|
|
const mockPlugin = {
|
|
name: 'Tool1',
|
|
pluginKey: 'tool1',
|
|
description: 'Tool 1',
|
|
// No authConfig means checkPluginAuth returns false
|
|
};
|
|
|
|
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
|
|
|
mockCache.get.mockResolvedValue(null);
|
|
// getCachedTools returns the tool definitions
|
|
getCachedTools.mockResolvedValueOnce({
|
|
tool1: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'tool1',
|
|
description: 'Tool 1',
|
|
parameters: {},
|
|
},
|
|
},
|
|
});
|
|
mockReq.config = {
|
|
mcpConfig: null,
|
|
paths: { structuredTools: '/mock/path' },
|
|
};
|
|
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
const responseData = mockRes.json.mock.calls[0][0];
|
|
expect(Array.isArray(responseData)).toBe(true);
|
|
const tool = responseData.find((t) => t.pluginKey === 'tool1');
|
|
expect(tool).toBeDefined();
|
|
// The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added
|
|
expect(tool.authenticated).toBeUndefined();
|
|
});
|
|
|
|
it('should use getToolkitKey for toolkit validation', async () => {
|
|
const mockToolkit = {
|
|
name: 'Toolkit1',
|
|
pluginKey: 'toolkit1',
|
|
description: 'Toolkit 1',
|
|
toolkit: true,
|
|
};
|
|
|
|
require('~/app/clients/tools').availableTools.push(mockToolkit);
|
|
|
|
// Mock toolkits to have a mapping
|
|
require('~/app/clients/tools').toolkits.push({
|
|
name: 'Toolkit1',
|
|
pluginKey: 'toolkit1',
|
|
tools: ['toolkit1_function'],
|
|
});
|
|
|
|
mockCache.get.mockResolvedValue(null);
|
|
// getCachedTools returns the tool definitions
|
|
getCachedTools.mockResolvedValueOnce({
|
|
toolkit1_function: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'toolkit1_function',
|
|
description: 'Toolkit function',
|
|
parameters: {},
|
|
},
|
|
},
|
|
});
|
|
mockReq.config = {
|
|
mcpConfig: null,
|
|
paths: { structuredTools: '/mock/path' },
|
|
};
|
|
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
const responseData = mockRes.json.mock.calls[0][0];
|
|
expect(Array.isArray(responseData)).toBe(true);
|
|
const toolkit = responseData.find((t) => t.pluginKey === 'toolkit1');
|
|
expect(toolkit).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('helper function integration', () => {
|
|
it('should handle error cases gracefully', async () => {
|
|
mockCache.get.mockRejectedValue(new Error('Cache error'));
|
|
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(500);
|
|
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Cache error' });
|
|
});
|
|
});
|
|
|
|
describe('edge cases with undefined/null values', () => {
|
|
it('should handle undefined cache gracefully', async () => {
|
|
getLogStores.mockReturnValue(undefined);
|
|
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(500);
|
|
});
|
|
|
|
it('should handle null cachedTools and cachedUserTools', async () => {
|
|
mockCache.get.mockResolvedValue(null);
|
|
// getCachedTools returns empty object instead of null
|
|
getCachedTools.mockResolvedValueOnce({});
|
|
mockReq.config = {
|
|
mcpConfig: null,
|
|
paths: { structuredTools: '/mock/path' },
|
|
};
|
|
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
// Should handle null values gracefully
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
expect(mockRes.json).toHaveBeenCalledWith([]);
|
|
});
|
|
|
|
it('should handle when getCachedTools returns undefined', async () => {
|
|
mockCache.get.mockResolvedValue(null);
|
|
mockReq.config = {
|
|
mcpConfig: null,
|
|
paths: { structuredTools: '/mock/path' },
|
|
};
|
|
|
|
// Mock getCachedTools to return undefined
|
|
getCachedTools.mockReset();
|
|
getCachedTools.mockResolvedValueOnce(undefined);
|
|
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
// Should handle undefined values gracefully
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
expect(mockRes.json).toHaveBeenCalledWith([]);
|
|
});
|
|
|
|
it('should handle empty toolDefinitions object', async () => {
|
|
mockCache.get.mockResolvedValue(null);
|
|
// Reset getCachedTools to ensure clean state
|
|
getCachedTools.mockReset();
|
|
getCachedTools.mockResolvedValue({});
|
|
mockReq.config = {}; // No mcpConfig at all
|
|
|
|
// Ensure no plugins are available
|
|
require('~/app/clients/tools').availableTools.length = 0;
|
|
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
// With empty tool definitions, no tools should be in the final output
|
|
expect(mockRes.json).toHaveBeenCalledWith([]);
|
|
});
|
|
|
|
it('should handle undefined filteredTools and includedTools', async () => {
|
|
mockReq.config = {};
|
|
mockCache.get.mockResolvedValue(null);
|
|
|
|
// Configure getAppConfig to return config with undefined properties
|
|
// The controller will use default values [] for filteredTools and includedTools
|
|
getAppConfig.mockResolvedValueOnce({});
|
|
|
|
await getAvailablePluginsController(mockReq, mockRes);
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
expect(mockRes.json).toHaveBeenCalledWith([]);
|
|
});
|
|
|
|
it('should handle toolkit with undefined toolDefinitions keys', async () => {
|
|
const mockToolkit = {
|
|
name: 'Toolkit1',
|
|
pluginKey: 'toolkit1',
|
|
description: 'Toolkit 1',
|
|
toolkit: true,
|
|
};
|
|
|
|
// No need to mock app.locals anymore as it's not used
|
|
|
|
// Add the toolkit to availableTools
|
|
require('~/app/clients/tools').availableTools.push(mockToolkit);
|
|
|
|
mockCache.get.mockResolvedValue(null);
|
|
// getCachedTools returns empty object to avoid null reference error
|
|
getCachedTools.mockResolvedValueOnce({});
|
|
mockReq.config = {
|
|
mcpConfig: null,
|
|
paths: { structuredTools: '/mock/path' },
|
|
};
|
|
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
// Should handle null toolDefinitions gracefully
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
});
|
|
|
|
it('should handle undefined toolDefinitions when checking isToolDefined (traversaal_search bug)', async () => {
|
|
// This test reproduces the bug where toolDefinitions is undefined
|
|
// and accessing toolDefinitions[plugin.pluginKey] causes a TypeError
|
|
const mockPlugin = {
|
|
name: 'Traversaal Search',
|
|
pluginKey: 'traversaal_search',
|
|
description: 'Search plugin',
|
|
};
|
|
|
|
// Add the plugin to availableTools
|
|
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
|
|
|
mockCache.get.mockResolvedValue(null);
|
|
|
|
mockReq.config = {
|
|
mcpConfig: null,
|
|
paths: { structuredTools: '/mock/path' },
|
|
};
|
|
|
|
// CRITICAL: getCachedTools returns undefined
|
|
// This is what causes the bug when trying to access toolDefinitions[plugin.pluginKey]
|
|
getCachedTools.mockResolvedValueOnce(undefined);
|
|
|
|
// This should not throw an error with the optional chaining fix
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
// Should handle undefined toolDefinitions gracefully and return empty array
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
expect(mockRes.json).toHaveBeenCalledWith([]);
|
|
});
|
|
|
|
it('should re-initialize tools from appConfig when cache returns null', async () => {
|
|
// Setup: Initial state with tools in appConfig
|
|
const mockAppTools = {
|
|
tool1: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'tool1',
|
|
description: 'Tool 1',
|
|
parameters: {},
|
|
},
|
|
},
|
|
tool2: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'tool2',
|
|
description: 'Tool 2',
|
|
parameters: {},
|
|
},
|
|
},
|
|
};
|
|
|
|
// Add matching plugins to availableTools
|
|
require('~/app/clients/tools').availableTools.push(
|
|
{ name: 'Tool 1', pluginKey: 'tool1', description: 'Tool 1' },
|
|
{ name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' },
|
|
);
|
|
|
|
// Simulate cache cleared state (returns null)
|
|
mockCache.get.mockResolvedValue(null);
|
|
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
|
|
|
|
mockReq.config = {
|
|
filteredTools: [],
|
|
includedTools: [],
|
|
availableTools: mockAppTools,
|
|
};
|
|
|
|
// Mock setCachedTools to verify it's called to re-initialize
|
|
const { setCachedTools } = require('~/server/services/Config');
|
|
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
// Should have re-initialized the cache with tools from appConfig
|
|
expect(setCachedTools).toHaveBeenCalledWith(mockAppTools);
|
|
|
|
// Should still return tools successfully
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
const responseData = mockRes.json.mock.calls[0][0];
|
|
expect(responseData).toHaveLength(2);
|
|
expect(responseData.find((t) => t.pluginKey === 'tool1')).toBeDefined();
|
|
expect(responseData.find((t) => t.pluginKey === 'tool2')).toBeDefined();
|
|
});
|
|
|
|
it('should handle cache clear without appConfig.availableTools gracefully', async () => {
|
|
// Setup: appConfig without availableTools
|
|
getAppConfig.mockResolvedValue({
|
|
filteredTools: [],
|
|
includedTools: [],
|
|
// No availableTools property
|
|
});
|
|
|
|
// Clear availableTools array
|
|
require('~/app/clients/tools').availableTools.length = 0;
|
|
|
|
// Cache returns null (cleared state)
|
|
mockCache.get.mockResolvedValue(null);
|
|
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
|
|
|
|
mockReq.config = {
|
|
filteredTools: [],
|
|
includedTools: [],
|
|
// No availableTools
|
|
};
|
|
|
|
await getAvailableTools(mockReq, mockRes);
|
|
|
|
// Should handle gracefully without crashing
|
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
|
expect(mockRes.json).toHaveBeenCalledWith([]);
|
|
});
|
|
});
|
|
});
|