mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 05:23:08 +00:00
* fix: Fix critical session cleanup stack overflow bug (v2.19.2) This commit fixes a critical P0 bug that caused stack overflow during container restart, making the service unusable for all users with session persistence enabled. Root Causes: 1. Missing await in cleanupExpiredSessions() line 206 caused overlapping async cleanup attempts 2. Transport event handlers (onclose, onerror) triggered recursive cleanup during shutdown 3. No recursion guard to prevent concurrent cleanup of same session Fixes Applied: - Added cleanupInProgress Set recursion guard - Added isShuttingDown flag to prevent recursive event handlers - Implemented safeCloseTransport() with timeout protection (3s) - Updated removeSession() with recursion guard and safe close - Fixed cleanupExpiredSessions() to properly await with error isolation - Updated all transport event handlers to check shutdown flag - Enhanced shutdown() method for proper sequential cleanup Impact: - Service now survives container restarts without stack overflow - No more hanging requests after restart - Individual session cleanup failures don't cascade - All 77 session lifecycle tests passing Version: 2.19.2 Severity: CRITICAL Priority: P0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: Bump package.runtime.json to v2.19.2 * test: Fix transport cleanup test to work with safeCloseTransport The test was manually triggering mockTransport.onclose() to simulate cleanup, but our stack overflow fix sets transport.onclose = undefined in safeCloseTransport() before closing. Updated the test to call removeSession() directly instead of manually triggering the onclose handler. This properly tests the cleanup behavior with the new recursion-safe approach. Changes: - Call removeSession() directly to test cleanup - Verify transport.close() is called - Verify onclose and onerror handlers are cleared - Verify all session data structures are cleaned up Test Results: All 115 session tests passing ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1130 lines
37 KiB
TypeScript
1130 lines
37 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi, MockedFunction } from 'vitest';
|
|
import type { Request, Response, NextFunction } from 'express';
|
|
import { SingleSessionHTTPServer } from '../../src/http-server-single-session';
|
|
|
|
// Mock dependencies
|
|
vi.mock('../../src/utils/logger', () => ({
|
|
logger: {
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn()
|
|
}
|
|
}));
|
|
|
|
vi.mock('dotenv');
|
|
|
|
// Mock UUID generation to make tests predictable
|
|
vi.mock('uuid', () => ({
|
|
v4: vi.fn(() => 'test-session-id-1234-5678-9012-345678901234')
|
|
}));
|
|
|
|
// Mock transport with session cleanup
|
|
const mockTransports: { [key: string]: any } = {};
|
|
|
|
vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
|
|
StreamableHTTPServerTransport: vi.fn().mockImplementation((options: any) => {
|
|
const mockTransport = {
|
|
handleRequest: vi.fn().mockImplementation(async (req: any, res: any, body?: any) => {
|
|
// For initialize requests, set the session ID header
|
|
if (body && body.method === 'initialize') {
|
|
res.setHeader('Mcp-Session-Id', mockTransport.sessionId || 'test-session-id');
|
|
}
|
|
res.status(200).json({
|
|
jsonrpc: '2.0',
|
|
result: { success: true },
|
|
id: body?.id || 1
|
|
});
|
|
}),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
sessionId: null as string | null,
|
|
onclose: null as (() => void) | null
|
|
};
|
|
|
|
// Store reference for cleanup tracking
|
|
if (options?.sessionIdGenerator) {
|
|
const sessionId = options.sessionIdGenerator();
|
|
mockTransport.sessionId = sessionId;
|
|
mockTransports[sessionId] = mockTransport;
|
|
|
|
// Simulate session initialization callback
|
|
if (options.onsessioninitialized) {
|
|
setTimeout(() => {
|
|
options.onsessioninitialized(sessionId);
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
return mockTransport;
|
|
})
|
|
}));
|
|
|
|
vi.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
|
|
SSEServerTransport: vi.fn().mockImplementation(() => ({
|
|
close: vi.fn().mockResolvedValue(undefined)
|
|
}))
|
|
}));
|
|
|
|
vi.mock('../../src/mcp/server', () => ({
|
|
N8NDocumentationMCPServer: vi.fn().mockImplementation(() => ({
|
|
connect: vi.fn().mockResolvedValue(undefined)
|
|
}))
|
|
}));
|
|
|
|
// Mock console manager
|
|
const mockConsoleManager = {
|
|
wrapOperation: vi.fn().mockImplementation(async (fn: () => Promise<any>) => {
|
|
return await fn();
|
|
})
|
|
};
|
|
|
|
vi.mock('../../src/utils/console-manager', () => ({
|
|
ConsoleManager: vi.fn(() => mockConsoleManager)
|
|
}));
|
|
|
|
vi.mock('../../src/utils/url-detector', () => ({
|
|
getStartupBaseUrl: vi.fn((host: string, port: number) => `http://localhost:${port || 3000}`),
|
|
formatEndpointUrls: vi.fn((baseUrl: string) => ({
|
|
health: `${baseUrl}/health`,
|
|
mcp: `${baseUrl}/mcp`
|
|
})),
|
|
detectBaseUrl: vi.fn((req: any, host: string, port: number) => `http://localhost:${port || 3000}`)
|
|
}));
|
|
|
|
vi.mock('../../src/utils/version', () => ({
|
|
PROJECT_VERSION: '2.8.3'
|
|
}));
|
|
|
|
// Mock isInitializeRequest
|
|
vi.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
|
isInitializeRequest: vi.fn((request: any) => {
|
|
return request && request.method === 'initialize';
|
|
})
|
|
}));
|
|
|
|
// Create handlers storage for Express mock
|
|
const mockHandlers: { [key: string]: any[] } = {
|
|
get: [],
|
|
post: [],
|
|
delete: [],
|
|
use: []
|
|
};
|
|
|
|
// Mock Express
|
|
vi.mock('express', () => {
|
|
const mockExpressApp = {
|
|
get: vi.fn((path: string, ...handlers: any[]) => {
|
|
mockHandlers.get.push({ path, handlers });
|
|
return mockExpressApp;
|
|
}),
|
|
post: vi.fn((path: string, ...handlers: any[]) => {
|
|
mockHandlers.post.push({ path, handlers });
|
|
return mockExpressApp;
|
|
}),
|
|
delete: vi.fn((path: string, ...handlers: any[]) => {
|
|
mockHandlers.delete.push({ path, handlers });
|
|
return mockExpressApp;
|
|
}),
|
|
use: vi.fn((handler: any) => {
|
|
mockHandlers.use.push(handler);
|
|
return mockExpressApp;
|
|
}),
|
|
set: vi.fn(),
|
|
listen: vi.fn((port: number, host: string, callback?: () => void) => {
|
|
if (callback) callback();
|
|
return {
|
|
on: vi.fn(),
|
|
close: vi.fn((cb: () => void) => cb()),
|
|
address: () => ({ port: 3000 })
|
|
};
|
|
})
|
|
};
|
|
|
|
interface ExpressMock {
|
|
(): typeof mockExpressApp;
|
|
json(): (req: any, res: any, next: any) => void;
|
|
}
|
|
|
|
const expressMock = vi.fn(() => mockExpressApp) as unknown as ExpressMock;
|
|
expressMock.json = vi.fn(() => (req: any, res: any, next: any) => {
|
|
req.body = req.body || {};
|
|
next();
|
|
});
|
|
|
|
return {
|
|
default: expressMock,
|
|
Request: {},
|
|
Response: {},
|
|
NextFunction: {}
|
|
};
|
|
});
|
|
|
|
describe('HTTP Server Session Management', () => {
|
|
const originalEnv = process.env;
|
|
const TEST_AUTH_TOKEN = 'test-auth-token-with-more-than-32-characters';
|
|
let server: SingleSessionHTTPServer;
|
|
let consoleLogSpy: any;
|
|
let consoleWarnSpy: any;
|
|
let consoleErrorSpy: any;
|
|
|
|
beforeEach(() => {
|
|
// Reset environment
|
|
process.env = { ...originalEnv };
|
|
process.env.AUTH_TOKEN = TEST_AUTH_TOKEN;
|
|
process.env.PORT = '0';
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
// Mock console methods
|
|
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// Clear all mocks and handlers
|
|
vi.clearAllMocks();
|
|
mockHandlers.get = [];
|
|
mockHandlers.post = [];
|
|
mockHandlers.delete = [];
|
|
mockHandlers.use = [];
|
|
|
|
// Clear mock transports
|
|
Object.keys(mockTransports).forEach(key => delete mockTransports[key]);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Restore environment
|
|
process.env = originalEnv;
|
|
|
|
// Restore console methods
|
|
consoleLogSpy.mockRestore();
|
|
consoleWarnSpy.mockRestore();
|
|
consoleErrorSpy.mockRestore();
|
|
|
|
// Shutdown server if running
|
|
if (server) {
|
|
await server.shutdown();
|
|
server = null as any;
|
|
}
|
|
});
|
|
|
|
// Helper functions
|
|
function findHandler(method: 'get' | 'post' | 'delete', path: string) {
|
|
const routes = mockHandlers[method];
|
|
const route = routes.find(r => r.path === path);
|
|
return route ? route.handlers[route.handlers.length - 1] : null;
|
|
}
|
|
|
|
function createMockReqRes() {
|
|
const headers: { [key: string]: string } = {};
|
|
const res = {
|
|
status: vi.fn().mockReturnThis(),
|
|
json: vi.fn().mockReturnThis(),
|
|
send: vi.fn().mockReturnThis(),
|
|
setHeader: vi.fn((key: string, value: string) => {
|
|
headers[key.toLowerCase()] = value;
|
|
}),
|
|
sendStatus: vi.fn().mockReturnThis(),
|
|
headersSent: false,
|
|
finished: false,
|
|
statusCode: 200,
|
|
getHeader: (key: string) => headers[key.toLowerCase()],
|
|
headers
|
|
};
|
|
|
|
const req = {
|
|
method: 'GET',
|
|
path: '/',
|
|
url: '/',
|
|
originalUrl: '/',
|
|
headers: {} as Record<string, string>,
|
|
body: {},
|
|
ip: '127.0.0.1',
|
|
readable: true,
|
|
readableEnded: false,
|
|
complete: true,
|
|
get: vi.fn((header: string) => (req.headers as Record<string, string>)[header.toLowerCase()])
|
|
};
|
|
|
|
return { req, res };
|
|
}
|
|
|
|
describe('Session Creation and Limits', () => {
|
|
it('should allow creation of sessions up to MAX_SESSIONS limit', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('post', '/mcp');
|
|
expect(handler).toBeTruthy();
|
|
|
|
// Create multiple sessions up to the limit (100)
|
|
// For testing purposes, we'll test a smaller number
|
|
const testSessionCount = 3;
|
|
|
|
for (let i = 0; i < testSessionCount; i++) {
|
|
const { req, res } = createMockReqRes();
|
|
req.headers = {
|
|
authorization: `Bearer ${TEST_AUTH_TOKEN}`
|
|
// No session ID header to force new session creation
|
|
};
|
|
req.method = 'POST';
|
|
req.body = {
|
|
jsonrpc: '2.0',
|
|
method: 'initialize',
|
|
params: {},
|
|
id: i + 1
|
|
};
|
|
|
|
await handler(req, res);
|
|
|
|
// Should not return 429 (too many sessions) yet
|
|
expect(res.status).not.toHaveBeenCalledWith(429);
|
|
|
|
// Add small delay to allow for session initialization callback
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}
|
|
|
|
// Allow some time for all session initialization callbacks to complete
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// Verify session info shows multiple sessions
|
|
const sessionInfo = server.getSessionInfo();
|
|
// At minimum, we should have some sessions created (exact count may vary due to async nature)
|
|
expect(sessionInfo.sessions?.total).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('should reject new sessions when MAX_SESSIONS limit is reached', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
// Test canCreateSession method directly when at limit
|
|
(server as any).getActiveSessionCount = vi.fn().mockReturnValue(100);
|
|
const canCreate = (server as any).canCreateSession();
|
|
expect(canCreate).toBe(false);
|
|
|
|
// Test the method logic works correctly
|
|
(server as any).getActiveSessionCount = vi.fn().mockReturnValue(50);
|
|
const canCreateUnderLimit = (server as any).canCreateSession();
|
|
expect(canCreateUnderLimit).toBe(true);
|
|
|
|
// For the HTTP handler test, we would need a more complex setup
|
|
// This test verifies the core logic is working
|
|
});
|
|
|
|
it('should validate canCreateSession method behavior', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
// Test canCreateSession method directly
|
|
const canCreate1 = (server as any).canCreateSession();
|
|
expect(canCreate1).toBe(true); // Initially should be true
|
|
|
|
// Mock active session count to be at limit
|
|
(server as any).getActiveSessionCount = vi.fn().mockReturnValue(100);
|
|
const canCreate2 = (server as any).canCreateSession();
|
|
expect(canCreate2).toBe(false); // Should be false when at limit
|
|
|
|
// Mock active session count to be under limit
|
|
(server as any).getActiveSessionCount = vi.fn().mockReturnValue(50);
|
|
const canCreate3 = (server as any).canCreateSession();
|
|
expect(canCreate3).toBe(true); // Should be true when under limit
|
|
});
|
|
});
|
|
|
|
describe('Session Expiration and Cleanup', () => {
|
|
it('should clean up expired sessions', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
// Mock expired sessions
|
|
const mockSessionMetadata = {
|
|
'session-1': {
|
|
lastAccess: new Date(Date.now() - 40 * 60 * 1000), // 40 minutes ago (expired)
|
|
createdAt: new Date(Date.now() - 60 * 60 * 1000)
|
|
},
|
|
'session-2': {
|
|
lastAccess: new Date(Date.now() - 10 * 60 * 1000), // 10 minutes ago (not expired)
|
|
createdAt: new Date(Date.now() - 20 * 60 * 1000)
|
|
}
|
|
};
|
|
|
|
(server as any).sessionMetadata = mockSessionMetadata;
|
|
(server as any).transports = {
|
|
'session-1': { close: vi.fn() },
|
|
'session-2': { close: vi.fn() }
|
|
};
|
|
(server as any).servers = {
|
|
'session-1': {},
|
|
'session-2': {}
|
|
};
|
|
|
|
// Trigger cleanup manually
|
|
await (server as any).cleanupExpiredSessions();
|
|
|
|
// Expired session should be removed
|
|
expect((server as any).sessionMetadata['session-1']).toBeUndefined();
|
|
expect((server as any).transports['session-1']).toBeUndefined();
|
|
expect((server as any).servers['session-1']).toBeUndefined();
|
|
|
|
// Non-expired session should remain
|
|
expect((server as any).sessionMetadata['session-2']).toBeDefined();
|
|
expect((server as any).transports['session-2']).toBeDefined();
|
|
expect((server as any).servers['session-2']).toBeDefined();
|
|
});
|
|
|
|
it('should start and stop session cleanup timer', async () => {
|
|
const setIntervalSpy = vi.spyOn(global, 'setInterval');
|
|
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
|
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
// Should start cleanup timer on construction
|
|
expect(setIntervalSpy).toHaveBeenCalled();
|
|
expect((server as any).cleanupTimer).toBeTruthy();
|
|
|
|
await server.shutdown();
|
|
|
|
// Should clear cleanup timer on shutdown
|
|
expect(clearIntervalSpy).toHaveBeenCalled();
|
|
expect((server as any).cleanupTimer).toBe(null);
|
|
|
|
setIntervalSpy.mockRestore();
|
|
clearIntervalSpy.mockRestore();
|
|
});
|
|
|
|
it('should handle removeSession method correctly', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
const mockTransport = { close: vi.fn().mockResolvedValue(undefined) };
|
|
(server as any).transports = { 'test-session': mockTransport };
|
|
(server as any).servers = { 'test-session': {} };
|
|
(server as any).sessionMetadata = {
|
|
'test-session': {
|
|
lastAccess: new Date(),
|
|
createdAt: new Date()
|
|
}
|
|
};
|
|
|
|
await (server as any).removeSession('test-session', 'test-removal');
|
|
|
|
expect(mockTransport.close).toHaveBeenCalled();
|
|
expect((server as any).transports['test-session']).toBeUndefined();
|
|
expect((server as any).servers['test-session']).toBeUndefined();
|
|
expect((server as any).sessionMetadata['test-session']).toBeUndefined();
|
|
});
|
|
|
|
it('should handle removeSession with transport close error gracefully', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
const mockTransport = {
|
|
close: vi.fn().mockRejectedValue(new Error('Transport close failed'))
|
|
};
|
|
(server as any).transports = { 'test-session': mockTransport };
|
|
(server as any).servers = { 'test-session': {} };
|
|
(server as any).sessionMetadata = {
|
|
'test-session': {
|
|
lastAccess: new Date(),
|
|
createdAt: new Date()
|
|
}
|
|
};
|
|
|
|
// Should not throw even if transport close fails
|
|
await expect((server as any).removeSession('test-session', 'test-removal')).resolves.toBeUndefined();
|
|
|
|
// Verify transport close was attempted
|
|
expect(mockTransport.close).toHaveBeenCalled();
|
|
|
|
// Session should still be cleaned up despite transport error
|
|
// Note: The actual implementation may handle errors differently, so let's verify what we can
|
|
expect(mockTransport.close).toHaveBeenCalledWith();
|
|
});
|
|
});
|
|
|
|
describe('Session Metadata Tracking', () => {
|
|
it('should track session metadata correctly', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
const sessionId = 'test-session-123';
|
|
const mockMetadata = {
|
|
lastAccess: new Date(),
|
|
createdAt: new Date()
|
|
};
|
|
|
|
(server as any).sessionMetadata[sessionId] = mockMetadata;
|
|
|
|
// Test updateSessionAccess
|
|
const originalTime = mockMetadata.lastAccess.getTime();
|
|
await new Promise(resolve => setTimeout(resolve, 10)); // Small delay
|
|
(server as any).updateSessionAccess(sessionId);
|
|
|
|
expect((server as any).sessionMetadata[sessionId].lastAccess.getTime()).toBeGreaterThan(originalTime);
|
|
});
|
|
|
|
it('should get session metrics correctly', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
const now = Date.now();
|
|
(server as any).sessionMetadata = {
|
|
'active-session': {
|
|
lastAccess: new Date(now - 10 * 60 * 1000), // 10 minutes ago
|
|
createdAt: new Date(now - 20 * 60 * 1000)
|
|
},
|
|
'expired-session': {
|
|
lastAccess: new Date(now - 40 * 60 * 1000), // 40 minutes ago (expired)
|
|
createdAt: new Date(now - 60 * 60 * 1000)
|
|
}
|
|
};
|
|
(server as any).transports = {
|
|
'active-session': {},
|
|
'expired-session': {}
|
|
};
|
|
|
|
const metrics = (server as any).getSessionMetrics();
|
|
|
|
expect(metrics.totalSessions).toBe(2);
|
|
expect(metrics.activeSessions).toBe(2);
|
|
expect(metrics.expiredSessions).toBe(1);
|
|
expect(metrics.lastCleanup).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('should get active session count correctly', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
(server as any).transports = {
|
|
'session-1': {},
|
|
'session-2': {},
|
|
'session-3': {}
|
|
};
|
|
|
|
const count = (server as any).getActiveSessionCount();
|
|
expect(count).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('Security Features', () => {
|
|
describe('Production Mode with Default Token', () => {
|
|
it('should throw error in production with default token', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
process.env.AUTH_TOKEN = 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh';
|
|
|
|
expect(() => {
|
|
new SingleSessionHTTPServer();
|
|
}).toThrow('CRITICAL SECURITY ERROR: Cannot start in production with default AUTH_TOKEN');
|
|
});
|
|
|
|
it('should allow default token in development', () => {
|
|
process.env.NODE_ENV = 'development';
|
|
process.env.AUTH_TOKEN = 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh';
|
|
|
|
expect(() => {
|
|
new SingleSessionHTTPServer();
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should allow default token when NODE_ENV is not set', () => {
|
|
const originalNodeEnv = process.env.NODE_ENV;
|
|
delete (process.env as any).NODE_ENV;
|
|
process.env.AUTH_TOKEN = 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh';
|
|
|
|
expect(() => {
|
|
new SingleSessionHTTPServer();
|
|
}).not.toThrow();
|
|
|
|
// Restore original value
|
|
if (originalNodeEnv !== undefined) {
|
|
process.env.NODE_ENV = originalNodeEnv;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Token Validation', () => {
|
|
it('should warn about short tokens', () => {
|
|
process.env.AUTH_TOKEN = 'short_token';
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
expect(() => {
|
|
new SingleSessionHTTPServer();
|
|
}).not.toThrow();
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('should validate minimum token length (32 characters)', () => {
|
|
process.env.AUTH_TOKEN = 'this_token_is_31_characters_long';
|
|
|
|
expect(() => {
|
|
new SingleSessionHTTPServer();
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should throw error when AUTH_TOKEN is empty', () => {
|
|
process.env.AUTH_TOKEN = '';
|
|
|
|
expect(() => {
|
|
new SingleSessionHTTPServer();
|
|
}).toThrow('No authentication token found or token is empty');
|
|
});
|
|
|
|
it('should throw error when AUTH_TOKEN is missing', () => {
|
|
delete process.env.AUTH_TOKEN;
|
|
|
|
expect(() => {
|
|
new SingleSessionHTTPServer();
|
|
}).toThrow('No authentication token found or token is empty');
|
|
});
|
|
|
|
it('should load token from AUTH_TOKEN_FILE', () => {
|
|
delete process.env.AUTH_TOKEN;
|
|
process.env.AUTH_TOKEN_FILE = '/fake/token/file';
|
|
|
|
// Mock fs.readFileSync before creating server
|
|
vi.doMock('fs', () => ({
|
|
readFileSync: vi.fn().mockReturnValue('file-based-token-32-characters-long')
|
|
}));
|
|
|
|
// For this test, we need to set a valid token since fs mocking is complex in vitest
|
|
process.env.AUTH_TOKEN = 'file-based-token-32-characters-long';
|
|
|
|
expect(() => {
|
|
new SingleSessionHTTPServer();
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Security Info in Health Endpoint', () => {
|
|
it('should include security information in health endpoint', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('get', '/health');
|
|
expect(handler).toBeTruthy();
|
|
|
|
const { req, res } = createMockReqRes();
|
|
await handler(req, res);
|
|
|
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
|
security: {
|
|
production: false, // NODE_ENV is 'test'
|
|
defaultToken: false, // Using TEST_AUTH_TOKEN
|
|
tokenLength: TEST_AUTH_TOKEN.length
|
|
}
|
|
}));
|
|
});
|
|
|
|
it('should show default token warning in health endpoint', async () => {
|
|
process.env.AUTH_TOKEN = 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh';
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('get', '/health');
|
|
const { req, res } = createMockReqRes();
|
|
await handler(req, res);
|
|
|
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
|
security: {
|
|
production: false,
|
|
defaultToken: true,
|
|
tokenLength: 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh'.length
|
|
}
|
|
}));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Transport Management', () => {
|
|
it('should handle transport cleanup on close', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
// Test the transport cleanup mechanism by calling removeSession directly
|
|
const sessionId = 'test-session-id-1234-5678-9012-345678901234';
|
|
const mockTransport = {
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
sessionId,
|
|
onclose: undefined as (() => void) | undefined,
|
|
onerror: undefined as ((error: Error) => void) | undefined
|
|
};
|
|
|
|
(server as any).transports[sessionId] = mockTransport;
|
|
(server as any).servers[sessionId] = {};
|
|
(server as any).sessionMetadata[sessionId] = {
|
|
lastAccess: new Date(),
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// Directly call removeSession to test cleanup behavior
|
|
await (server as any).removeSession(sessionId, 'transport_closed');
|
|
|
|
// Verify cleanup completed
|
|
expect((server as any).transports[sessionId]).toBeUndefined();
|
|
expect((server as any).servers[sessionId]).toBeUndefined();
|
|
expect((server as any).sessionMetadata[sessionId]).toBeUndefined();
|
|
expect(mockTransport.close).toHaveBeenCalled();
|
|
expect(mockTransport.onclose).toBeUndefined();
|
|
expect(mockTransport.onerror).toBeUndefined();
|
|
});
|
|
|
|
it('should handle multiple concurrent sessions', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('post', '/mcp');
|
|
|
|
// Create multiple concurrent sessions
|
|
const promises = [];
|
|
for (let i = 0; i < 3; i++) {
|
|
const { req, res } = createMockReqRes();
|
|
req.headers = { authorization: `Bearer ${TEST_AUTH_TOKEN}` };
|
|
req.method = 'POST';
|
|
req.body = {
|
|
jsonrpc: '2.0',
|
|
method: 'initialize',
|
|
params: {},
|
|
id: i + 1
|
|
};
|
|
|
|
promises.push(handler(req, res));
|
|
}
|
|
|
|
await Promise.all(promises);
|
|
|
|
// All should succeed (no 429 errors)
|
|
// This tests that concurrent session creation works
|
|
expect(true).toBe(true); // If we get here, all sessions were created successfully
|
|
});
|
|
|
|
it('should handle session-specific transport instances', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('post', '/mcp');
|
|
|
|
// Create first session
|
|
const { req: req1, res: res1 } = createMockReqRes();
|
|
req1.headers = { authorization: `Bearer ${TEST_AUTH_TOKEN}` };
|
|
req1.method = 'POST';
|
|
req1.body = {
|
|
jsonrpc: '2.0',
|
|
method: 'initialize',
|
|
params: {},
|
|
id: 1
|
|
};
|
|
|
|
await handler(req1, res1);
|
|
const sessionId1 = 'test-session-id-1234-5678-9012-345678901234';
|
|
|
|
// Make subsequent request with same session ID
|
|
const { req: req2, res: res2 } = createMockReqRes();
|
|
req2.headers = {
|
|
authorization: `Bearer ${TEST_AUTH_TOKEN}`,
|
|
'mcp-session-id': sessionId1
|
|
};
|
|
req2.method = 'POST';
|
|
req2.body = {
|
|
jsonrpc: '2.0',
|
|
method: 'test_method',
|
|
params: {},
|
|
id: 2
|
|
};
|
|
|
|
await handler(req2, res2);
|
|
|
|
// Should reuse existing transport for the session
|
|
expect(res2.status).not.toHaveBeenCalledWith(400);
|
|
});
|
|
});
|
|
|
|
describe('New Endpoints', () => {
|
|
describe('DELETE /mcp Endpoint', () => {
|
|
it('should terminate session successfully', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('delete', '/mcp');
|
|
expect(handler).toBeTruthy();
|
|
|
|
// Set up a mock session with valid UUID
|
|
const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
|
|
(server as any).transports[sessionId] = { close: vi.fn().mockResolvedValue(undefined) };
|
|
(server as any).servers[sessionId] = {};
|
|
(server as any).sessionMetadata[sessionId] = {
|
|
lastAccess: new Date(),
|
|
createdAt: new Date()
|
|
};
|
|
|
|
const { req, res } = createMockReqRes();
|
|
req.headers = { 'mcp-session-id': sessionId };
|
|
req.method = 'DELETE';
|
|
|
|
await handler(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(204);
|
|
expect((server as any).transports[sessionId]).toBeUndefined();
|
|
});
|
|
|
|
it('should return 400 when Mcp-Session-Id header is missing', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('delete', '/mcp');
|
|
const { req, res } = createMockReqRes();
|
|
req.method = 'DELETE';
|
|
|
|
await handler(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
jsonrpc: '2.0',
|
|
error: {
|
|
code: -32602,
|
|
message: 'Mcp-Session-Id header is required'
|
|
},
|
|
id: null
|
|
});
|
|
});
|
|
|
|
it('should return 404 for non-existent session (any format accepted)', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('delete', '/mcp');
|
|
|
|
// Test various session ID formats - all should pass validation
|
|
// but return 404 if session doesn't exist
|
|
const sessionIds = [
|
|
'invalid-session-id',
|
|
'instance-user123-abc-uuid',
|
|
'mcp-remote-session-xyz',
|
|
'short-id',
|
|
'12345'
|
|
];
|
|
|
|
for (const sessionId of sessionIds) {
|
|
const { req, res } = createMockReqRes();
|
|
req.headers = { 'mcp-session-id': sessionId };
|
|
req.method = 'DELETE';
|
|
|
|
await handler(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(404); // Session not found
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
jsonrpc: '2.0',
|
|
error: {
|
|
code: -32001,
|
|
message: 'Session not found'
|
|
},
|
|
id: null
|
|
});
|
|
}
|
|
});
|
|
|
|
it('should return 400 for empty session ID', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('delete', '/mcp');
|
|
const { req, res } = createMockReqRes();
|
|
req.headers = { 'mcp-session-id': '' };
|
|
req.method = 'DELETE';
|
|
|
|
await handler(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
jsonrpc: '2.0',
|
|
error: {
|
|
code: -32602,
|
|
message: 'Mcp-Session-Id header is required'
|
|
},
|
|
id: null
|
|
});
|
|
});
|
|
|
|
it('should return 404 when session not found', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('delete', '/mcp');
|
|
const { req, res } = createMockReqRes();
|
|
req.headers = { 'mcp-session-id': 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' };
|
|
req.method = 'DELETE';
|
|
|
|
await handler(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(404);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
jsonrpc: '2.0',
|
|
error: {
|
|
code: -32001,
|
|
message: 'Session not found'
|
|
},
|
|
id: null
|
|
});
|
|
});
|
|
|
|
it('should handle termination errors gracefully', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('delete', '/mcp');
|
|
|
|
// Set up a mock session that will fail to close with valid UUID
|
|
const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
|
|
const mockRemoveSession = vi.spyOn(server as any, 'removeSession')
|
|
.mockRejectedValue(new Error('Failed to remove session'));
|
|
|
|
(server as any).transports[sessionId] = { close: vi.fn() };
|
|
|
|
const { req, res } = createMockReqRes();
|
|
req.headers = { 'mcp-session-id': sessionId };
|
|
req.method = 'DELETE';
|
|
|
|
await handler(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(500);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
jsonrpc: '2.0',
|
|
error: {
|
|
code: -32603,
|
|
message: 'Error terminating session'
|
|
},
|
|
id: null
|
|
});
|
|
|
|
mockRemoveSession.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('Enhanced Health Endpoint', () => {
|
|
it('should include session statistics in health endpoint', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const handler = findHandler('get', '/health');
|
|
const { req, res } = createMockReqRes();
|
|
await handler(req, res);
|
|
|
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
|
status: 'ok',
|
|
mode: 'sdk-pattern-transports',
|
|
version: '2.8.3',
|
|
sessions: expect.objectContaining({
|
|
active: expect.any(Number),
|
|
total: expect.any(Number),
|
|
expired: expect.any(Number),
|
|
max: 100,
|
|
usage: expect.any(String),
|
|
sessionIds: expect.any(Array)
|
|
}),
|
|
security: expect.objectContaining({
|
|
production: expect.any(Boolean),
|
|
defaultToken: expect.any(Boolean),
|
|
tokenLength: expect.any(Number)
|
|
})
|
|
}));
|
|
});
|
|
|
|
it('should show correct session usage format', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
// Mock session metrics
|
|
(server as any).getSessionMetrics = vi.fn().mockReturnValue({
|
|
activeSessions: 25,
|
|
totalSessions: 30,
|
|
expiredSessions: 5,
|
|
lastCleanup: new Date()
|
|
});
|
|
|
|
const handler = findHandler('get', '/health');
|
|
const { req, res } = createMockReqRes();
|
|
await handler(req, res);
|
|
|
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
|
sessions: expect.objectContaining({
|
|
usage: '25/100'
|
|
})
|
|
}));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Session ID Validation', () => {
|
|
it('should accept any non-empty string as session ID', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
// Valid session IDs - any non-empty string is accepted
|
|
const validSessionIds = [
|
|
// UUIDv4 format (existing format - still valid)
|
|
'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
|
'12345678-1234-4567-8901-123456789012',
|
|
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
|
|
// Instance-prefixed format (multi-tenant)
|
|
'instance-user123-abc123-550e8400-e29b-41d4-a716-446655440000',
|
|
|
|
// Custom formats (mcp-remote, proxies, etc.)
|
|
'mcp-remote-session-xyz',
|
|
'custom-session-format',
|
|
'short-uuid',
|
|
'invalid-uuid', // "invalid" UUID is valid as generic string
|
|
'12345',
|
|
|
|
// Even "wrong" UUID versions are accepted (relaxed validation)
|
|
'aaaaaaaa-bbbb-3ccc-8ddd-eeeeeeeeeeee', // UUID v3
|
|
'aaaaaaaa-bbbb-4ccc-cddd-eeeeeeeeeeee', // Wrong variant
|
|
'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee-extra', // Extra chars
|
|
|
|
// Any non-empty string works
|
|
'anything-goes'
|
|
];
|
|
|
|
// Invalid session IDs - only empty strings
|
|
const invalidSessionIds = [
|
|
''
|
|
];
|
|
|
|
// All non-empty strings should be accepted
|
|
for (const sessionId of validSessionIds) {
|
|
expect((server as any).isValidSessionId(sessionId)).toBe(true);
|
|
}
|
|
|
|
// Only empty strings should be rejected
|
|
for (const sessionId of invalidSessionIds) {
|
|
expect((server as any).isValidSessionId(sessionId)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('should accept non-empty strings, reject only empty strings', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
// These should all be ACCEPTED (return true) - any non-empty string
|
|
expect((server as any).isValidSessionId('invalid-session-id')).toBe(true);
|
|
expect((server as any).isValidSessionId('short')).toBe(true);
|
|
expect((server as any).isValidSessionId('instance-user-abc-123')).toBe(true);
|
|
expect((server as any).isValidSessionId('mcp-remote-xyz')).toBe(true);
|
|
expect((server as any).isValidSessionId('12345')).toBe(true);
|
|
expect((server as any).isValidSessionId('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee')).toBe(true);
|
|
|
|
// Only empty string should be REJECTED (return false)
|
|
expect((server as any).isValidSessionId('')).toBe(false);
|
|
});
|
|
|
|
it('should reject requests with non-existent session ID', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
// Test that a valid UUID format passes validation
|
|
const validUUID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
|
|
expect((server as any).isValidSessionId(validUUID)).toBe(true);
|
|
|
|
// But the session won't exist in the transports map initially
|
|
expect((server as any).transports[validUUID]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Shutdown and Cleanup', () => {
|
|
it('should clean up all resources on shutdown', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
// Set up mock sessions
|
|
const mockTransport1 = { close: vi.fn().mockResolvedValue(undefined) };
|
|
const mockTransport2 = { close: vi.fn().mockResolvedValue(undefined) };
|
|
|
|
(server as any).transports = {
|
|
'session-1': mockTransport1,
|
|
'session-2': mockTransport2
|
|
};
|
|
(server as any).servers = {
|
|
'session-1': {},
|
|
'session-2': {}
|
|
};
|
|
(server as any).sessionMetadata = {
|
|
'session-1': { lastAccess: new Date(), createdAt: new Date() },
|
|
'session-2': { lastAccess: new Date(), createdAt: new Date() }
|
|
};
|
|
|
|
// Set up legacy session for SSE compatibility
|
|
const mockLegacyTransport = { close: vi.fn().mockResolvedValue(undefined) };
|
|
(server as any).session = {
|
|
transport: mockLegacyTransport
|
|
};
|
|
|
|
await server.shutdown();
|
|
|
|
// All transports should be closed
|
|
expect(mockTransport1.close).toHaveBeenCalled();
|
|
expect(mockTransport2.close).toHaveBeenCalled();
|
|
expect(mockLegacyTransport.close).toHaveBeenCalled();
|
|
|
|
// All data structures should be cleared
|
|
expect(Object.keys((server as any).transports)).toHaveLength(0);
|
|
expect(Object.keys((server as any).servers)).toHaveLength(0);
|
|
expect(Object.keys((server as any).sessionMetadata)).toHaveLength(0);
|
|
expect((server as any).session).toBe(null);
|
|
});
|
|
|
|
it('should handle transport close errors during shutdown', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
await server.start();
|
|
|
|
const mockTransport = {
|
|
close: vi.fn().mockRejectedValue(new Error('Transport close failed'))
|
|
};
|
|
|
|
(server as any).transports = { 'session-1': mockTransport };
|
|
(server as any).servers = { 'session-1': {} };
|
|
(server as any).sessionMetadata = {
|
|
'session-1': { lastAccess: new Date(), createdAt: new Date() }
|
|
};
|
|
|
|
// Should not throw even if transport close fails
|
|
await expect(server.shutdown()).resolves.toBeUndefined();
|
|
|
|
// Transport close should have been attempted
|
|
expect(mockTransport.close).toHaveBeenCalled();
|
|
|
|
// Verify shutdown completed without throwing
|
|
expect(server.shutdown).toBeDefined();
|
|
expect(typeof server.shutdown).toBe('function');
|
|
});
|
|
});
|
|
|
|
describe('getSessionInfo Method', () => {
|
|
it('should return correct session info structure', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
const sessionInfo = server.getSessionInfo();
|
|
|
|
expect(sessionInfo).toHaveProperty('active');
|
|
expect(sessionInfo).toHaveProperty('sessions');
|
|
expect(sessionInfo.sessions).toHaveProperty('total');
|
|
expect(sessionInfo.sessions).toHaveProperty('active');
|
|
expect(sessionInfo.sessions).toHaveProperty('expired');
|
|
expect(sessionInfo.sessions).toHaveProperty('max');
|
|
expect(sessionInfo.sessions).toHaveProperty('sessionIds');
|
|
|
|
expect(typeof sessionInfo.active).toBe('boolean');
|
|
expect(sessionInfo.sessions).toBeDefined();
|
|
expect(typeof sessionInfo.sessions!.total).toBe('number');
|
|
expect(typeof sessionInfo.sessions!.active).toBe('number');
|
|
expect(typeof sessionInfo.sessions!.expired).toBe('number');
|
|
expect(sessionInfo.sessions!.max).toBe(100);
|
|
expect(Array.isArray(sessionInfo.sessions!.sessionIds)).toBe(true);
|
|
});
|
|
|
|
it('should show legacy SSE session when present', async () => {
|
|
server = new SingleSessionHTTPServer();
|
|
|
|
// Mock legacy session
|
|
const mockSession = {
|
|
sessionId: 'sse-session-123',
|
|
lastAccess: new Date(),
|
|
isSSE: true
|
|
};
|
|
(server as any).session = mockSession;
|
|
|
|
const sessionInfo = server.getSessionInfo();
|
|
|
|
expect(sessionInfo.active).toBe(true);
|
|
expect(sessionInfo.sessionId).toBe('sse-session-123');
|
|
expect(sessionInfo.age).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
}); |