Files
n8n-mcp/tests/unit/http-server-session-management.test.ts
Romuald Członkowski 318986f546 🚨 HOTFIX v2.19.2: Fix critical session cleanup stack overflow (#316)
* 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>
2025-10-13 11:54:18 +02:00

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);
});
});
});