Revert to v2.18.10 - Remove session persistence (v2.19.0-v2.19.5) (#322)

After 5 consecutive hotfix attempts, session persistence has proven
architecturally incompatible with the MCP SDK. Rolling back to last
known stable version.

## Removed
- 16 new files (session types, docs, tests, planning docs)
- 1,100+ lines of session persistence code
- Session restoration hooks and lifecycle events
- Retry policy and warm-start implementations

## Restored
- Stable v2.18.10 codebase
- Library export fields (from PR #310)
- All core MCP functionality

## Breaking Changes
- Session persistence APIs removed
- onSessionNotFound hook removed
- Session lifecycle events removed

This reverts commits fe13091 through 1d34ad8.
Restores commit 4566253 (v2.18.10, PR #310).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2025-10-14 10:13:43 +02:00
committed by GitHub
parent fe1309151a
commit 8d20c64f5c
25 changed files with 97 additions and 13086 deletions

View File

@@ -1,747 +0,0 @@
/**
* Integration tests for Session Lifecycle Events (Phase 3) and Retry Policy (Phase 4)
*
* Tests complete event flow and retry behavior in realistic scenarios
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { N8NMCPEngine } from '../../src/mcp-engine';
import { InstanceContext } from '../../src/types/instance-context';
import { SessionRestoreHook, SessionState } from '../../src/types/session-restoration';
import type { Request, Response } from 'express';
// In-memory session storage for testing
const sessionStorage: Map<string, SessionState> = new Map();
/**
* Mock session store with failure simulation
*/
class MockSessionStore {
private failureCount = 0;
private maxFailures = 0;
/**
* Configure transient failures for retry testing
*/
setTransientFailures(count: number): void {
this.failureCount = 0;
this.maxFailures = count;
}
async saveSession(sessionState: SessionState): Promise<void> {
sessionStorage.set(sessionState.sessionId, {
...sessionState,
lastAccess: sessionState.lastAccess || new Date(),
expiresAt: sessionState.expiresAt || new Date(Date.now() + 30 * 60 * 1000)
});
}
async loadSession(sessionId: string): Promise<InstanceContext | null> {
// Simulate transient failures
if (this.failureCount < this.maxFailures) {
this.failureCount++;
throw new Error(`Transient database error (attempt ${this.failureCount})`);
}
const session = sessionStorage.get(sessionId);
if (!session) return null;
// Check if expired
if (session.expiresAt < new Date()) {
sessionStorage.delete(sessionId);
return null;
}
return session.instanceContext;
}
async deleteSession(sessionId: string): Promise<void> {
sessionStorage.delete(sessionId);
}
clear(): void {
sessionStorage.clear();
this.failureCount = 0;
this.maxFailures = 0;
}
}
describe('Session Lifecycle Events & Retry Policy Integration Tests', () => {
const TEST_AUTH_TOKEN = 'lifecycle-retry-test-token-32-chars-min';
let mockStore: MockSessionStore;
let originalEnv: NodeJS.ProcessEnv;
// Event tracking
let eventLog: Array<{ event: string; sessionId: string; timestamp: number }> = [];
beforeEach(() => {
// Save and set environment
originalEnv = { ...process.env };
process.env.AUTH_TOKEN = TEST_AUTH_TOKEN;
process.env.PORT = '0';
process.env.NODE_ENV = 'test';
// Use in-memory database for tests - these tests focus on session lifecycle,
// not node queries, so we don't need the full node database
process.env.NODE_DB_PATH = ':memory:';
// Clear storage and events
mockStore = new MockSessionStore();
mockStore.clear();
eventLog = [];
});
afterEach(() => {
// Restore environment
process.env = originalEnv;
mockStore.clear();
eventLog = [];
vi.clearAllMocks();
});
// Helper to create properly mocked Request and Response objects
// Simplified to match working session-persistence test - SDK doesn't need full socket mock
function createMockReqRes(sessionId?: string, body?: any) {
const req = {
method: 'POST',
path: '/mcp',
url: '/mcp',
originalUrl: '/mcp',
headers: {
'authorization': `Bearer ${TEST_AUTH_TOKEN}`,
...(sessionId && { 'mcp-session-id': sessionId })
} as Record<string, string>,
body: body || {
jsonrpc: '2.0',
method: 'tools/list',
params: {},
id: 1
},
ip: '127.0.0.1',
readable: true,
readableEnded: false,
complete: true,
get: vi.fn((header: string) => req.headers[header.toLowerCase()]),
on: vi.fn((event: string, handler: Function) => {}),
removeListener: vi.fn((event: string, handler: Function) => {})
} as any as Request;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
setHeader: vi.fn(),
send: vi.fn().mockReturnThis(),
writeHead: vi.fn().mockReturnThis(),
write: vi.fn(),
end: vi.fn(),
flushHeaders: vi.fn(),
on: vi.fn((event: string, handler: Function) => res),
once: vi.fn((event: string, handler: Function) => res),
removeListener: vi.fn(),
headersSent: false,
finished: false
} as any as Response;
return { req, res };
}
// Helper to track events
function createEventTracker() {
return {
onSessionCreated: vi.fn((sessionId: string) => {
eventLog.push({ event: 'created', sessionId, timestamp: Date.now() });
}),
onSessionRestored: vi.fn((sessionId: string) => {
eventLog.push({ event: 'restored', sessionId, timestamp: Date.now() });
}),
onSessionAccessed: vi.fn((sessionId: string) => {
eventLog.push({ event: 'accessed', sessionId, timestamp: Date.now() });
}),
onSessionExpired: vi.fn((sessionId: string) => {
eventLog.push({ event: 'expired', sessionId, timestamp: Date.now() });
}),
onSessionDeleted: vi.fn((sessionId: string) => {
eventLog.push({ event: 'deleted', sessionId, timestamp: Date.now() });
})
};
}
describe('Phase 3: Session Lifecycle Events', () => {
it('should emit onSessionCreated for new sessions', async () => {
const events = createEventTracker();
const engine = new N8NMCPEngine({
sessionEvents: events
});
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
// Create session using public API
const sessionId = 'instance-test-abc-new-session-lifecycle-test';
const created = engine.restoreSession(sessionId, context);
expect(created).toBe(true);
// Give fire-and-forget events a moment
await new Promise(resolve => setTimeout(resolve, 50));
// Should have emitted onSessionCreated
expect(events.onSessionCreated).toHaveBeenCalledTimes(1);
expect(events.onSessionCreated).toHaveBeenCalledWith(sessionId, context);
await engine.shutdown();
});
it('should emit onSessionRestored when restoring from storage', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://tenant1.n8n.cloud',
n8nApiKey: 'tenant1-key',
instanceId: 'tenant-1'
};
const sessionId = 'instance-tenant-1-abc-restored-session-test';
// Persist session
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
const restorationHook: SessionRestoreHook = async (sid) => {
return await mockStore.loadSession(sid);
};
const events = createEventTracker();
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook,
sessionEvents: events
});
// Process request that triggers restoration (DON'T pass context - let it restore)
const { req: mockReq, res: mockRes } = createMockReqRes(sessionId);
await engine.processRequest(mockReq, mockRes);
// Give fire-and-forget events a moment
await new Promise(resolve => setTimeout(resolve, 50));
// Should emit onSessionRestored (not onSessionCreated)
// Note: If context was passed to processRequest, it would create instead of restore
expect(events.onSessionRestored).toHaveBeenCalledTimes(1);
expect(events.onSessionRestored).toHaveBeenCalledWith(sessionId, context);
await engine.shutdown();
});
it('should emit onSessionDeleted when session is manually deleted', async () => {
const events = createEventTracker();
const engine = new N8NMCPEngine({
sessionEvents: events
});
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = 'instance-testinstance-abc-550e8400e29b41d4a716446655440001';
// Create session by calling restoreSession
const created = engine.restoreSession(sessionId, context);
expect(created).toBe(true);
// Verify session exists
expect(engine.getActiveSessions()).toContain(sessionId);
// Give creation event time to fire
await new Promise(resolve => setTimeout(resolve, 50));
// Delete session
const deleted = engine.deleteSession(sessionId);
expect(deleted).toBe(true);
// Verify session was deleted
expect(engine.getActiveSessions()).not.toContain(sessionId);
// Give deletion event time to fire
await new Promise(resolve => setTimeout(resolve, 50));
// Should emit onSessionDeleted
expect(events.onSessionDeleted).toHaveBeenCalledTimes(1);
expect(events.onSessionDeleted).toHaveBeenCalledWith(sessionId);
await engine.shutdown();
});
it('should handle event handler errors gracefully', async () => {
const errorHandler = vi.fn(() => {
throw new Error('Event handler error');
});
const engine = new N8NMCPEngine({
sessionEvents: {
onSessionCreated: errorHandler
}
});
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = 'instance-test-abc-error-handler-test';
// Should not throw despite handler error
expect(() => {
engine.restoreSession(sessionId, context);
}).not.toThrow();
// Session should still be created
expect(engine.getActiveSessions()).toContain(sessionId);
await engine.shutdown();
});
it('should emit events with correct metadata', async () => {
const events = createEventTracker();
const engine = new N8NMCPEngine({
sessionEvents: events
});
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance',
metadata: {
userId: 'user-456',
tier: 'enterprise'
}
};
const sessionId = 'instance-test-abc-metadata-test';
engine.restoreSession(sessionId, context);
// Give event time to fire
await new Promise(resolve => setTimeout(resolve, 50));
expect(events.onSessionCreated).toHaveBeenCalledWith(
sessionId,
expect.objectContaining({
metadata: {
userId: 'user-456',
tier: 'enterprise'
}
})
);
await engine.shutdown();
});
});
describe('Phase 4: Retry Policy', () => {
it('should retry transient failures and eventually succeed', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = 'instance-testinst-abc-550e8400e29b41d4a716446655440002';
// Persist session
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
// Configure to fail twice, then succeed
mockStore.setTransientFailures(2);
const restorationHook: SessionRestoreHook = async (sid) => {
return await mockStore.loadSession(sid);
};
const events = createEventTracker();
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook,
sessionRestorationRetries: 3, // Allow up to 3 retries
sessionRestorationRetryDelay: 50, // Fast retries for testing
sessionEvents: events
});
const { req: mockReq, res: mockRes} = createMockReqRes(sessionId);
await engine.processRequest(mockReq, mockRes); // Don't pass context - let it restore
// Give events time to fire
await new Promise(resolve => setTimeout(resolve, 100));
// Should have succeeded (not 500 error)
expect(mockRes.status).not.toHaveBeenCalledWith(500);
// Should emit onSessionRestored after successful retry
expect(events.onSessionRestored).toHaveBeenCalledTimes(1);
await engine.shutdown();
});
it('should fail after exhausting all retries', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = 'instance-test-abc-retry-exhaust-test';
// Persist session
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
// Configure to fail 5 times (more than max retries)
mockStore.setTransientFailures(5);
const restorationHook: SessionRestoreHook = async (sid) => {
return await mockStore.loadSession(sid);
};
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook,
sessionRestorationRetries: 2, // Only 2 retries
sessionRestorationRetryDelay: 50
});
const { req: mockReq, res: mockRes } = createMockReqRes(sessionId);
await engine.processRequest(mockReq, mockRes); // Don't pass context
// Should fail with 500 error
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: expect.stringMatching(/restoration failed|error/i)
})
})
);
await engine.shutdown();
});
it('should not retry timeout errors', async () => {
const slowHook: SessionRestoreHook = async () => {
// Simulate very slow query
await new Promise(resolve => setTimeout(resolve, 500));
return {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test'
};
};
const engine = new N8NMCPEngine({
onSessionNotFound: slowHook,
sessionRestorationRetries: 3,
sessionRestorationRetryDelay: 50,
sessionRestorationTimeout: 100 // Very short timeout
});
const { req: mockReq, res: mockRes } = createMockReqRes('instance-test-abc-timeout-no-retry');
await engine.processRequest(mockReq, mockRes);
// Should timeout with 408
expect(mockRes.status).toHaveBeenCalledWith(408);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: expect.stringMatching(/timeout|timed out/i)
})
})
);
await engine.shutdown();
});
it('should respect overall timeout across all retry attempts', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = 'instance-test-abc-overall-timeout-test';
// Persist session
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
// Configure many failures
mockStore.setTransientFailures(10);
const restorationHook: SessionRestoreHook = async (sid) => {
// Each attempt takes 100ms
await new Promise(resolve => setTimeout(resolve, 100));
return await mockStore.loadSession(sid);
};
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook,
sessionRestorationRetries: 10, // Many retries
sessionRestorationRetryDelay: 100,
sessionRestorationTimeout: 300 // Overall timeout for ALL attempts
});
const { req: mockReq, res: mockRes } = createMockReqRes(sessionId);
await engine.processRequest(mockReq, mockRes); // Don't pass context
// Should timeout before exhausting retries
expect(mockRes.status).toHaveBeenCalledWith(408);
await engine.shutdown();
});
});
describe('Phase 3 + 4: Combined Behavior', () => {
it('should emit onSessionRestored after successful retry', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = 'instance-testinst-abc-550e8400e29b41d4a716446655440003';
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
// Fail once, then succeed
mockStore.setTransientFailures(1);
const restorationHook: SessionRestoreHook = async (sid) => {
return await mockStore.loadSession(sid);
};
const events = createEventTracker();
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook,
sessionRestorationRetries: 2,
sessionRestorationRetryDelay: 50,
sessionEvents: events
});
const { req: mockReq, res: mockRes } = createMockReqRes(sessionId);
await engine.processRequest(mockReq, mockRes); // Don't pass context
// Give events time to fire
await new Promise(resolve => setTimeout(resolve, 100));
// Should have succeeded
expect(mockRes.status).not.toHaveBeenCalledWith(500);
// Should emit onSessionRestored after successful retry
expect(events.onSessionRestored).toHaveBeenCalledTimes(1);
expect(events.onSessionRestored).toHaveBeenCalledWith(sessionId, context);
await engine.shutdown();
});
it('should not emit events if all retries fail', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = 'instance-test-abc-retry-fail-no-event';
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
// Always fail
mockStore.setTransientFailures(10);
const restorationHook: SessionRestoreHook = async (sid) => {
return await mockStore.loadSession(sid);
};
const events = createEventTracker();
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook,
sessionRestorationRetries: 2,
sessionRestorationRetryDelay: 50,
sessionEvents: events
});
const { req: mockReq, res: mockRes } = createMockReqRes(sessionId);
await engine.processRequest(mockReq, mockRes); // Don't pass context
// Give events time to fire (they shouldn't)
await new Promise(resolve => setTimeout(resolve, 100));
// Should have failed
expect(mockRes.status).toHaveBeenCalledWith(500);
// Should NOT emit onSessionRestored
expect(events.onSessionRestored).not.toHaveBeenCalled();
expect(events.onSessionCreated).not.toHaveBeenCalled();
await engine.shutdown();
});
it('should handle event handler errors during retry workflow', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = 'instance-testinst-abc-550e8400e29b41d4a716446655440004';
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
// Fail once, then succeed
mockStore.setTransientFailures(1);
const restorationHook: SessionRestoreHook = async (sid) => {
return await mockStore.loadSession(sid);
};
const errorHandler = vi.fn(() => {
throw new Error('Event handler error');
});
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook,
sessionRestorationRetries: 2,
sessionRestorationRetryDelay: 50,
sessionEvents: {
onSessionRestored: errorHandler
}
});
const { req: mockReq, res: mockRes } = createMockReqRes(sessionId);
// Should not throw despite event handler error
await engine.processRequest(mockReq, mockRes); // Don't pass context
// Give event handler time to fail
await new Promise(resolve => setTimeout(resolve, 100));
// Request should still succeed (event error is non-blocking)
expect(mockRes.status).not.toHaveBeenCalledWith(500);
// Handler was called
expect(errorHandler).toHaveBeenCalledTimes(1);
await engine.shutdown();
});
});
describe('Backward Compatibility', () => {
it('should work without lifecycle events configured', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = 'instance-testinst-abc-550e8400e29b41d4a716446655440005';
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
const restorationHook: SessionRestoreHook = async (sid) => {
return await mockStore.loadSession(sid);
};
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook
// No sessionEvents configured
});
const { req: mockReq, res: mockRes } = createMockReqRes(sessionId);
await engine.processRequest(mockReq, mockRes); // Don't pass context
// Should work normally
expect(mockRes.status).not.toHaveBeenCalledWith(500);
await engine.shutdown();
});
it('should work with 0 retries (default behavior)', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = 'instance-test-abc-zero-retries';
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
// Fail once
mockStore.setTransientFailures(1);
const restorationHook: SessionRestoreHook = async (sid) => {
return await mockStore.loadSession(sid);
};
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook
// No sessionRestorationRetries - defaults to 0
});
const { req: mockReq, res: mockRes } = createMockReqRes(sessionId);
await engine.processRequest(mockReq, mockRes, context);
// Should fail immediately (no retries)
expect(mockRes.status).toHaveBeenCalledWith(500);
await engine.shutdown();
});
});
});

View File

@@ -1,600 +0,0 @@
/**
* Integration tests for session persistence (Phase 1)
*
* Tests the complete session restoration flow end-to-end,
* simulating real-world scenarios like container restarts and multi-tenant usage.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { N8NMCPEngine } from '../../src/mcp-engine';
import { SingleSessionHTTPServer } from '../../src/http-server-single-session';
import { InstanceContext } from '../../src/types/instance-context';
import { SessionRestoreHook, SessionState } from '../../src/types/session-restoration';
import type { Request, Response } from 'express';
// In-memory session storage for testing
const sessionStorage: Map<string, SessionState> = new Map();
/**
* Simulates a backend database for session persistence
*/
class MockSessionStore {
async saveSession(sessionState: SessionState): Promise<void> {
sessionStorage.set(sessionState.sessionId, {
...sessionState,
// Only update lastAccess and expiresAt if not provided
lastAccess: sessionState.lastAccess || new Date(),
expiresAt: sessionState.expiresAt || new Date(Date.now() + 30 * 60 * 1000) // 30 minutes
});
}
async loadSession(sessionId: string): Promise<SessionState | null> {
const session = sessionStorage.get(sessionId);
if (!session) return null;
// Check if expired
if (session.expiresAt < new Date()) {
sessionStorage.delete(sessionId);
return null;
}
// Update last access
session.lastAccess = new Date();
session.expiresAt = new Date(Date.now() + 30 * 60 * 1000);
sessionStorage.set(sessionId, session);
return session;
}
async deleteSession(sessionId: string): Promise<void> {
sessionStorage.delete(sessionId);
}
async cleanExpired(): Promise<number> {
const now = new Date();
let count = 0;
for (const [sessionId, session] of sessionStorage.entries()) {
if (session.expiresAt < now) {
sessionStorage.delete(sessionId);
count++;
}
}
return count;
}
getAllSessions(): Map<string, SessionState> {
return new Map(sessionStorage);
}
clear(): void {
sessionStorage.clear();
}
}
describe('Session Persistence Integration Tests', () => {
const TEST_AUTH_TOKEN = 'integration-test-token-with-32-chars-min-length';
let mockStore: MockSessionStore;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
// Save and set environment
originalEnv = { ...process.env };
process.env.AUTH_TOKEN = TEST_AUTH_TOKEN;
process.env.PORT = '0';
process.env.NODE_ENV = 'test';
// Clear session storage
mockStore = new MockSessionStore();
mockStore.clear();
});
afterEach(() => {
// Restore environment
process.env = originalEnv;
mockStore.clear();
});
// Helper to create properly mocked Request and Response objects
function createMockReqRes(sessionId?: string, body?: any) {
const req = {
method: 'POST',
path: '/mcp',
url: '/mcp',
originalUrl: '/mcp',
headers: {
'authorization': `Bearer ${TEST_AUTH_TOKEN}`,
...(sessionId && { 'mcp-session-id': sessionId })
} as Record<string, string>,
body: body || {
jsonrpc: '2.0',
method: 'tools/list',
params: {},
id: 1
},
ip: '127.0.0.1',
readable: true,
readableEnded: false,
complete: true,
get: vi.fn((header: string) => req.headers[header.toLowerCase()]),
on: vi.fn((event: string, handler: Function) => {}),
removeListener: vi.fn((event: string, handler: Function) => {})
} as any as Request;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
setHeader: vi.fn(),
send: vi.fn().mockReturnThis(),
headersSent: false,
finished: false
} as any as Response;
return { req, res };
}
describe('Container Restart Simulation', () => {
it('should restore session after simulated container restart', async () => {
// PHASE 1: Initial session creation
const context: InstanceContext = {
n8nApiUrl: 'https://tenant1.n8n.cloud',
n8nApiKey: 'tenant1-api-key',
instanceId: 'tenant-1'
};
const sessionId = 'instance-tenant-1-abc-550e8400-e29b-41d4-a716-446655440000';
// Simulate session being persisted by the backend
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
// PHASE 2: Simulate container restart (create new engine)
const restorationHook: SessionRestoreHook = async (sid) => {
const session = await mockStore.loadSession(sid);
return session ? session.instanceContext : null;
};
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook,
sessionRestorationTimeout: 5000
});
// PHASE 3: Client tries to use old session ID
const { req: mockReq, res: mockRes } = createMockReqRes(sessionId);
// Should successfully restore and process request
await engine.processRequest(mockReq, mockRes, context);
// Session should be restored (not return 400 for unknown session)
expect(mockRes.status).not.toHaveBeenCalledWith(400);
expect(mockRes.status).not.toHaveBeenCalledWith(404);
await engine.shutdown();
});
it('should reject expired sessions after container restart', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://tenant1.n8n.cloud',
n8nApiKey: 'tenant1-api-key',
instanceId: 'tenant-1'
};
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
// Save session with past expiration
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(Date.now() - 60 * 60 * 1000), // 1 hour ago
lastAccess: new Date(Date.now() - 45 * 60 * 1000), // 45 minutes ago
expiresAt: new Date(Date.now() - 15 * 60 * 1000) // Expired 15 minutes ago
});
const restorationHook: SessionRestoreHook = async (sid) => {
const session = await mockStore.loadSession(sid);
return session ? session.instanceContext : null;
};
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook,
sessionRestorationTimeout: 5000
});
const { req: mockReq, res: mockRes } = createMockReqRes(sessionId);
await engine.processRequest(mockReq, mockRes);
// Should reject expired session
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: expect.stringMatching(/session|not found/i)
})
})
);
await engine.shutdown();
});
});
describe('Multi-Tenant Session Restoration', () => {
it('should restore correct instance context for each tenant', async () => {
// Create sessions for multiple tenants
const tenant1Context: InstanceContext = {
n8nApiUrl: 'https://tenant1.n8n.cloud',
n8nApiKey: 'tenant1-key',
instanceId: 'tenant-1'
};
const tenant2Context: InstanceContext = {
n8nApiUrl: 'https://tenant2.n8n.cloud',
n8nApiKey: 'tenant2-key',
instanceId: 'tenant-2'
};
const sessionId1 = 'instance-tenant-1-abc-550e8400-e29b-41d4-a716-446655440000';
const sessionId2 = 'instance-tenant-2-xyz-f47ac10b-58cc-4372-a567-0e02b2c3d479';
await mockStore.saveSession({
sessionId: sessionId1,
instanceContext: tenant1Context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
await mockStore.saveSession({
sessionId: sessionId2,
instanceContext: tenant2Context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
const restorationHook: SessionRestoreHook = async (sid) => {
const session = await mockStore.loadSession(sid);
return session ? session.instanceContext : null;
};
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook,
sessionRestorationTimeout: 5000
});
// Verify each tenant gets their own context
const session1 = await mockStore.loadSession(sessionId1);
const session2 = await mockStore.loadSession(sessionId2);
expect(session1?.instanceContext.instanceId).toBe('tenant-1');
expect(session1?.instanceContext.n8nApiUrl).toBe('https://tenant1.n8n.cloud');
expect(session2?.instanceContext.instanceId).toBe('tenant-2');
expect(session2?.instanceContext.n8nApiUrl).toBe('https://tenant2.n8n.cloud');
await engine.shutdown();
});
it('should isolate sessions between tenants', async () => {
const tenant1Context: InstanceContext = {
n8nApiUrl: 'https://tenant1.n8n.cloud',
n8nApiKey: 'tenant1-key',
instanceId: 'tenant-1'
};
const sessionId = 'instance-tenant-1-abc-550e8400-e29b-41d4-a716-446655440000';
await mockStore.saveSession({
sessionId,
instanceContext: tenant1Context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
const restorationHook: SessionRestoreHook = async (sid) => {
const session = await mockStore.loadSession(sid);
return session ? session.instanceContext : null;
};
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook
});
// Tenant 2 tries to use tenant 1's session ID
const wrongSessionId = sessionId; // Tenant 1's ID
const { req: tenant2Request, res: mockRes } = createMockReqRes(wrongSessionId);
// The restoration will succeed (session exists), but the backend
// should implement authorization checks to prevent cross-tenant access
await engine.processRequest(tenant2Request, mockRes);
// Restoration should work (this test verifies the session CAN be restored)
// Authorization is the backend's responsibility
expect(mockRes.status).not.toHaveBeenCalledWith(404);
await engine.shutdown();
});
});
describe('Concurrent Restoration Requests', () => {
it('should handle multiple concurrent restoration requests for same session', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000)
});
let hookCallCount = 0;
const restorationHook: SessionRestoreHook = async (sid) => {
hookCallCount++;
// Simulate slow database query
await new Promise(resolve => setTimeout(resolve, 50));
const session = await mockStore.loadSession(sid);
return session ? session.instanceContext : null;
};
const engine = new N8NMCPEngine({
onSessionNotFound: restorationHook,
sessionRestorationTimeout: 5000
});
// Simulate 5 concurrent requests with same unknown session ID
const requests = Array.from({ length: 5 }, (_, i) => {
const { req: mockReq, res: mockRes } = createMockReqRes(sessionId, {
jsonrpc: '2.0',
method: 'tools/list',
params: {},
id: i + 1
});
return engine.processRequest(mockReq, mockRes, context);
});
// All should complete without error
await Promise.all(requests);
// Hook should be called multiple times (no built-in deduplication)
// This is expected - the idempotent session creation prevents duplicates
expect(hookCallCount).toBeGreaterThan(0);
await engine.shutdown();
});
});
describe('Database Failure Scenarios', () => {
it('should handle database connection failures gracefully', async () => {
const failingHook: SessionRestoreHook = async () => {
throw new Error('Database connection failed');
};
const engine = new N8NMCPEngine({
onSessionNotFound: failingHook,
sessionRestorationTimeout: 5000
});
const { req: mockReq, res: mockRes } = createMockReqRes('550e8400-e29b-41d4-a716-446655440000');
await engine.processRequest(mockReq, mockRes);
// Should return 500 for database errors
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: expect.stringMatching(/restoration failed|error/i)
})
})
);
await engine.shutdown();
});
it('should timeout on slow database queries', async () => {
const slowHook: SessionRestoreHook = async () => {
// Simulate very slow database query
await new Promise(resolve => setTimeout(resolve, 10000));
return {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test'
};
};
const engine = new N8NMCPEngine({
onSessionNotFound: slowHook,
sessionRestorationTimeout: 100 // 100ms timeout
});
const { req: mockReq, res: mockRes } = createMockReqRes('550e8400-e29b-41d4-a716-446655440000');
await engine.processRequest(mockReq, mockRes);
// Should return 408 for timeout
expect(mockRes.status).toHaveBeenCalledWith(408);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: expect.stringMatching(/timeout|timed out/i)
})
})
);
await engine.shutdown();
});
});
describe('Session Metadata Tracking', () => {
it('should track session metadata correctly', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance',
metadata: {
userId: 'user-123',
plan: 'premium'
}
};
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
metadata: {
userAgent: 'test-client/1.0',
ip: '192.168.1.1'
}
});
const session = await mockStore.loadSession(sessionId);
expect(session).toBeDefined();
expect(session?.instanceContext.metadata).toEqual({
userId: 'user-123',
plan: 'premium'
});
expect(session?.metadata).toEqual({
userAgent: 'test-client/1.0',
ip: '192.168.1.1'
});
});
it('should update last access time on restoration', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test-instance'
};
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
const originalLastAccess = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago
await mockStore.saveSession({
sessionId,
instanceContext: context,
createdAt: new Date(Date.now() - 20 * 60 * 1000),
lastAccess: originalLastAccess,
expiresAt: new Date(Date.now() + 20 * 60 * 1000)
});
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 100));
// Load session (simulates restoration)
const session = await mockStore.loadSession(sessionId);
expect(session).toBeDefined();
expect(session!.lastAccess.getTime()).toBeGreaterThan(originalLastAccess.getTime());
});
});
describe('Session Cleanup', () => {
it('should clean up expired sessions', async () => {
// Add multiple sessions with different expiration times
await mockStore.saveSession({
sessionId: 'session-1',
instanceContext: {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'key1',
instanceId: 'instance-1'
},
createdAt: new Date(Date.now() - 60 * 60 * 1000),
lastAccess: new Date(Date.now() - 45 * 60 * 1000),
expiresAt: new Date(Date.now() - 15 * 60 * 1000) // Expired
});
await mockStore.saveSession({
sessionId: 'session-2',
instanceContext: {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'key2',
instanceId: 'instance-2'
},
createdAt: new Date(),
lastAccess: new Date(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000) // Valid
});
const cleanedCount = await mockStore.cleanExpired();
expect(cleanedCount).toBe(1);
expect(mockStore.getAllSessions().size).toBe(1);
expect(mockStore.getAllSessions().has('session-2')).toBe(true);
expect(mockStore.getAllSessions().has('session-1')).toBe(false);
});
});
describe('Backwards Compatibility', () => {
it('should work without restoration hook (legacy behavior)', async () => {
// Engine without restoration hook should work normally
const engine = new N8NMCPEngine();
const sessionInfo = engine.getSessionInfo();
expect(sessionInfo).toBeDefined();
expect(sessionInfo.active).toBeDefined();
await engine.shutdown();
});
it('should not break existing session creation flow', async () => {
const engine = new N8NMCPEngine({
onSessionNotFound: async () => null
});
// Creating sessions should work normally
const sessionInfo = engine.getSessionInfo();
expect(sessionInfo).toBeDefined();
await engine.shutdown();
});
});
describe('Security Validation', () => {
it('should validate restored context before using it', async () => {
const invalidHook: SessionRestoreHook = async () => {
// Return context with malformed URL (truly invalid)
return {
n8nApiUrl: 'not-a-valid-url',
n8nApiKey: 'test-key',
instanceId: 'test'
} as any;
};
const engine = new N8NMCPEngine({
onSessionNotFound: invalidHook,
sessionRestorationTimeout: 5000
});
const { req: mockReq, res: mockRes } = createMockReqRes('550e8400-e29b-41d4-a716-446655440000');
await engine.processRequest(mockReq, mockRes);
// Should reject invalid context
expect(mockRes.status).toHaveBeenCalledWith(400);
await engine.shutdown();
});
});
});

View File

@@ -1,390 +0,0 @@
/**
* Integration tests for warm start session restoration (v2.19.5)
*
* Tests the simplified warm start pattern where:
* 1. Restoration creates session using existing createSession() flow
* 2. Current request is handled immediately through restored session
* 3. Client auto-retries with initialize on same connection (standard MCP -32000)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SingleSessionHTTPServer } from '../../src/http-server-single-session';
import { InstanceContext } from '../../src/types/instance-context';
import { SessionRestoreHook } from '../../src/types/session-restoration';
import type { Request, Response } from 'express';
describe('Warm Start Session Restoration Tests', () => {
const TEST_AUTH_TOKEN = 'warmstart-test-token-with-32-chars-min-length';
let server: SingleSessionHTTPServer;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
// Save and set environment
originalEnv = { ...process.env };
process.env.AUTH_TOKEN = TEST_AUTH_TOKEN;
process.env.PORT = '0';
process.env.NODE_ENV = 'test';
});
afterEach(async () => {
// Cleanup server
if (server) {
await server.shutdown();
}
// Restore environment
process.env = originalEnv;
});
// Helper to create mocked Request and Response
function createMockReqRes(sessionId?: string, body?: any) {
const req = {
method: 'POST',
path: '/mcp',
url: '/mcp',
originalUrl: '/mcp',
headers: {
authorization: `Bearer ${TEST_AUTH_TOKEN}`,
...(sessionId && { 'mcp-session-id': sessionId })
} as Record<string, string>,
body: body || {
jsonrpc: '2.0',
method: 'tools/list',
params: {},
id: 1
},
ip: '127.0.0.1',
readable: true,
readableEnded: false,
complete: true,
get: vi.fn((header: string) => req.headers[header.toLowerCase()]),
on: vi.fn(),
removeListener: vi.fn()
} as any as Request;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
setHeader: vi.fn(),
send: vi.fn().mockReturnThis(),
headersSent: false,
finished: false
} as any as Response;
return { req, res };
}
describe('Happy Path: Successful Restoration', () => {
it('should restore session and handle current request immediately', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'test-instance'
};
const sessionId = 'test-session-550e8400';
let restoredSessionId: string | null = null;
// Mock restoration hook that returns context
const restorationHook: SessionRestoreHook = async (sid) => {
restoredSessionId = sid;
return context;
};
server = new SingleSessionHTTPServer({
onSessionNotFound: restorationHook,
sessionRestorationTimeout: 5000
});
// Start server
await server.start();
// Client sends request with unknown session ID
const { req, res } = createMockReqRes(sessionId);
// Handle request
await server.handleRequest(req, res, context);
// Verify restoration hook was called
expect(restoredSessionId).toBe(sessionId);
// Verify response was handled (not rejected with 400/404)
// A successful restoration should not return these error codes
expect(res.status).not.toHaveBeenCalledWith(400);
expect(res.status).not.toHaveBeenCalledWith(404);
// Verify a response was sent (either success or -32000 for initialization)
expect(res.json).toHaveBeenCalled();
});
it('should emit onSessionRestored event after successful restoration', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'test-instance'
};
const sessionId = 'test-session-550e8400';
let restoredEventFired = false;
let restoredEventSessionId: string | null = null;
const restorationHook: SessionRestoreHook = async () => context;
server = new SingleSessionHTTPServer({
onSessionNotFound: restorationHook,
sessionEvents: {
onSessionRestored: (sid, ctx) => {
restoredEventFired = true;
restoredEventSessionId = sid;
}
}
});
await server.start();
const { req, res } = createMockReqRes(sessionId);
await server.handleRequest(req, res, context);
// Wait for async event
await new Promise(resolve => setTimeout(resolve, 100));
expect(restoredEventFired).toBe(true);
expect(restoredEventSessionId).toBe(sessionId);
});
});
describe('Failure Cleanup', () => {
it('should clean up session when restoration fails', async () => {
const sessionId = 'test-session-550e8400';
// Mock failing restoration hook
const failingHook: SessionRestoreHook = async () => {
throw new Error('Database connection failed');
};
server = new SingleSessionHTTPServer({
onSessionNotFound: failingHook,
sessionRestorationTimeout: 5000
});
await server.start();
const { req, res } = createMockReqRes(sessionId);
await server.handleRequest(req, res);
// Verify error response
expect(res.status).toHaveBeenCalledWith(500);
// Verify session was NOT created (cleanup happened)
const activeSessions = server.getActiveSessions();
expect(activeSessions).not.toContain(sessionId);
});
it('should clean up session when restoration times out', async () => {
const sessionId = 'test-session-550e8400';
// Mock slow restoration hook
const slowHook: SessionRestoreHook = async () => {
await new Promise(resolve => setTimeout(resolve, 10000)); // 10 seconds
return {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-key',
instanceId: 'test'
};
};
server = new SingleSessionHTTPServer({
onSessionNotFound: slowHook,
sessionRestorationTimeout: 100 // 100ms timeout
});
await server.start();
const { req, res } = createMockReqRes(sessionId);
await server.handleRequest(req, res);
// Verify timeout response
expect(res.status).toHaveBeenCalledWith(408);
// Verify session was cleaned up
const activeSessions = server.getActiveSessions();
expect(activeSessions).not.toContain(sessionId);
});
it('should clean up session when restored context is invalid', async () => {
const sessionId = 'test-session-550e8400';
// Mock hook returning invalid context
const invalidHook: SessionRestoreHook = async () => {
return {
n8nApiUrl: 'not-a-valid-url', // Invalid URL format
n8nApiKey: 'test-key',
instanceId: 'test'
} as any;
};
server = new SingleSessionHTTPServer({
onSessionNotFound: invalidHook,
sessionRestorationTimeout: 5000
});
await server.start();
const { req, res } = createMockReqRes(sessionId);
await server.handleRequest(req, res);
// Verify validation error response
expect(res.status).toHaveBeenCalledWith(400);
// Verify session was NOT created
const activeSessions = server.getActiveSessions();
expect(activeSessions).not.toContain(sessionId);
});
});
describe('Concurrent Idempotency', () => {
it('should handle concurrent restoration attempts for same session idempotently', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'test-instance'
};
const sessionId = 'test-session-550e8400';
let hookCallCount = 0;
// Mock restoration hook with slow query
const restorationHook: SessionRestoreHook = async () => {
hookCallCount++;
// Simulate slow database query
await new Promise(resolve => setTimeout(resolve, 50));
return context;
};
server = new SingleSessionHTTPServer({
onSessionNotFound: restorationHook,
sessionRestorationTimeout: 5000
});
await server.start();
// Send 5 concurrent requests with same unknown session ID
const requests = Array.from({ length: 5 }, (_, i) => {
const { req, res } = createMockReqRes(sessionId, {
jsonrpc: '2.0',
method: 'tools/list',
params: {},
id: i + 1
});
return server.handleRequest(req, res, context);
});
// All should complete without error (no unhandled rejections)
const results = await Promise.allSettled(requests);
// All requests should complete (either fulfilled or rejected)
expect(results.length).toBe(5);
// Hook should be called at least once (possibly more for concurrent requests)
expect(hookCallCount).toBeGreaterThan(0);
// None of the requests should fail with server errors (500)
// They may return -32000 for initialization, but that's expected
results.forEach((result, i) => {
if (result.status === 'rejected') {
// Unexpected rejection - fail the test
throw new Error(`Request ${i} failed unexpectedly: ${result.reason}`);
}
});
});
it('should reuse already-restored session for concurrent requests', async () => {
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'test-instance'
};
const sessionId = 'test-session-550e8400';
let hookCallCount = 0;
// Track restoration attempts
const restorationHook: SessionRestoreHook = async () => {
hookCallCount++;
return context;
};
server = new SingleSessionHTTPServer({
onSessionNotFound: restorationHook,
sessionRestorationTimeout: 5000
});
await server.start();
// First request triggers restoration
const { req: req1, res: res1 } = createMockReqRes(sessionId);
await server.handleRequest(req1, res1, context);
// Verify hook was called for first request
expect(hookCallCount).toBe(1);
// Second request with same session ID
const { req: req2, res: res2 } = createMockReqRes(sessionId);
await server.handleRequest(req2, res2, context);
// If session was reused, hook should not be called again
// (or called again if session wasn't fully initialized yet)
// Either way, both requests should complete without errors
expect(res1.json).toHaveBeenCalled();
expect(res2.json).toHaveBeenCalled();
});
});
describe('Restoration Hook Edge Cases', () => {
it('should handle restoration hook returning null (session rejected)', async () => {
const sessionId = 'test-session-550e8400';
// Hook explicitly rejects restoration
const rejectingHook: SessionRestoreHook = async () => null;
server = new SingleSessionHTTPServer({
onSessionNotFound: rejectingHook,
sessionRestorationTimeout: 5000
});
await server.start();
const { req, res } = createMockReqRes(sessionId);
await server.handleRequest(req, res);
// Verify rejection response
expect(res.status).toHaveBeenCalledWith(400);
// Verify session was NOT created
expect(server.getActiveSessions()).not.toContain(sessionId);
});
it('should handle restoration hook returning undefined (session rejected)', async () => {
const sessionId = 'test-session-550e8400';
// Hook returns undefined
const undefinedHook: SessionRestoreHook = async () => undefined as any;
server = new SingleSessionHTTPServer({
onSessionNotFound: undefinedHook,
sessionRestorationTimeout: 5000
});
await server.start();
const { req, res } = createMockReqRes(sessionId);
await server.handleRequest(req, res);
// Verify rejection response
expect(res.status).toHaveBeenCalledWith(400);
// Verify session was NOT created
expect(server.getActiveSessions()).not.toContain(sessionId);
});
});
});

View File

@@ -1,138 +0,0 @@
/**
* Test to verify that onSessionCreated event is fired during standard initialize flow
* This test addresses the bug reported in v2.19.0 where the event was not fired
* for sessions created during the initialize request.
*/
import { SingleSessionHTTPServer } from '../../../src/http-server-single-session';
import { InstanceContext } from '../../../src/types/instance-context';
// Mock environment setup
process.env.AUTH_TOKEN = 'test-token-for-n8n-testing-minimum-32-chars';
process.env.NODE_ENV = 'test';
process.env.PORT = '3456'; // Use different port to avoid conflicts
async function testOnSessionCreatedEvent() {
console.log('\n🧪 Test: onSessionCreated Event Firing During Initialize\n');
console.log('━'.repeat(60));
let eventFired = false;
let capturedSessionId: string | undefined;
let capturedContext: InstanceContext | undefined;
// Create server with onSessionCreated handler
const server = new SingleSessionHTTPServer({
sessionEvents: {
onSessionCreated: async (sessionId: string, instanceContext?: InstanceContext) => {
console.log('✅ onSessionCreated event fired!');
console.log(` Session ID: ${sessionId}`);
console.log(` Context: ${instanceContext ? 'Present' : 'Not provided'}`);
eventFired = true;
capturedSessionId = sessionId;
capturedContext = instanceContext;
}
}
});
try {
// Start the HTTP server
console.log('\n📡 Starting HTTP server...');
await server.start();
console.log('✅ Server started\n');
// Wait a moment for server to be ready
await new Promise(resolve => setTimeout(resolve, 500));
// Simulate an MCP initialize request
console.log('📤 Simulating MCP initialize request...');
const port = parseInt(process.env.PORT || '3456');
const fetch = (await import('node-fetch')).default;
const response = await fetch(`http://localhost:${port}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer test-token-for-n8n-testing-minimum-32-chars',
'Accept': 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: {
name: 'test-client',
version: '1.0.0'
}
},
id: 1
})
});
const result = await response.json() as any;
console.log('📥 Response received:', response.status);
console.log(' Response body:', JSON.stringify(result, null, 2));
// Wait a moment for event to be processed
await new Promise(resolve => setTimeout(resolve, 1000));
// Verify results
console.log('\n🔍 Verification:');
console.log('━'.repeat(60));
if (eventFired) {
console.log('✅ SUCCESS: onSessionCreated event was fired');
console.log(` Captured Session ID: ${capturedSessionId}`);
console.log(` Context provided: ${capturedContext !== undefined}`);
// Verify session is in active sessions list
const activeSessions = server.getActiveSessions();
console.log(`\n📊 Active sessions count: ${activeSessions.length}`);
if (activeSessions.length > 0) {
console.log('✅ Session registered in active sessions list');
console.log(` Session IDs: ${activeSessions.join(', ')}`);
} else {
console.log('❌ No active sessions found');
}
// Check if captured session ID is in active sessions
if (capturedSessionId && activeSessions.includes(capturedSessionId)) {
console.log('✅ Event session ID matches active session');
} else {
console.log('⚠️ Event session ID not found in active sessions');
}
console.log('\n🎉 TEST PASSED: Bug is fixed!');
console.log('━'.repeat(60));
} else {
console.log('❌ FAILURE: onSessionCreated event was NOT fired');
console.log('━'.repeat(60));
console.log('\n💔 TEST FAILED: Bug still exists');
}
// Cleanup
await server.shutdown();
return eventFired;
} catch (error) {
console.error('\n❌ Test error:', error);
await server.shutdown();
return false;
}
}
// Run the test
testOnSessionCreatedEvent()
.then(success => {
process.exit(success ? 0 : 1);
})
.catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});

View File

@@ -631,16 +631,15 @@ describe('HTTP Server Session Management', () => {
describe('Transport Management', () => {
it('should handle transport cleanup on close', async () => {
server = new SingleSessionHTTPServer();
// Test the transport cleanup mechanism by calling removeSession directly
// Test the transport cleanup mechanism by setting up a transport with onclose
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
onclose: null as (() => void) | null
};
(server as any).transports[sessionId] = mockTransport;
(server as any).servers[sessionId] = {};
(server as any).sessionMetadata[sessionId] = {
@@ -648,16 +647,18 @@ describe('HTTP Server Session Management', () => {
createdAt: new Date()
};
// Directly call removeSession to test cleanup behavior
await (server as any).removeSession(sessionId, 'transport_closed');
// Set up the onclose handler like the real implementation would
mockTransport.onclose = () => {
(server as any).removeSession(sessionId, 'transport_closed');
};
// Verify cleanup completed
// Simulate transport close
if (mockTransport.onclose) {
await mockTransport.onclose();
}
// Verify cleanup was triggered
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 () => {

View File

@@ -1,306 +0,0 @@
/**
* Unit tests for Session Lifecycle Events (Phase 3 - REQ-4)
* Tests event emission configuration and error handling
*
* Note: Events are fire-and-forget (non-blocking), so we test:
* 1. Configuration works without errors
* 2. Operations complete successfully even if handlers fail
* 3. Handlers don't block operations
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { N8NMCPEngine } from '../../src/mcp-engine';
import { InstanceContext } from '../../src/types/instance-context';
describe('Session Lifecycle Events (Phase 3 - REQ-4)', () => {
let engine: N8NMCPEngine;
const testContext: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'test-instance'
};
beforeEach(() => {
// Set required AUTH_TOKEN environment variable for testing
process.env.AUTH_TOKEN = 'test-token-for-session-lifecycle-events-testing-32chars';
});
describe('onSessionCreated event', () => {
it('should configure onSessionCreated handler without error', () => {
const onSessionCreated = vi.fn();
engine = new N8NMCPEngine({
sessionEvents: { onSessionCreated }
});
const sessionId = 'instance-test-abc123-uuid-created-test-1';
const result = engine.restoreSession(sessionId, testContext);
// Session should be created successfully
expect(result).toBe(true);
expect(engine.getActiveSessions()).toContain(sessionId);
});
it('should create session successfully even with handler error', () => {
const errorHandler = vi.fn(() => {
throw new Error('Event handler error');
});
engine = new N8NMCPEngine({
sessionEvents: { onSessionCreated: errorHandler }
});
const sessionId = 'instance-test-abc123-uuid-error-test';
// Should not throw despite handler error (non-blocking)
expect(() => {
engine.restoreSession(sessionId, testContext);
}).not.toThrow();
// Session should still be created successfully
expect(engine.getActiveSessions()).toContain(sessionId);
});
it('should support async handlers without blocking', () => {
const asyncHandler = vi.fn(async () => {
await new Promise(resolve => setTimeout(resolve, 100));
});
engine = new N8NMCPEngine({
sessionEvents: { onSessionCreated: asyncHandler }
});
const sessionId = 'instance-test-abc123-uuid-async-test';
// Should return immediately (non-blocking)
const startTime = Date.now();
engine.restoreSession(sessionId, testContext);
const endTime = Date.now();
// Should complete quickly (not wait for async handler)
expect(endTime - startTime).toBeLessThan(50);
expect(engine.getActiveSessions()).toContain(sessionId);
});
});
describe('onSessionDeleted event', () => {
it('should configure onSessionDeleted handler without error', () => {
const onSessionDeleted = vi.fn();
engine = new N8NMCPEngine({
sessionEvents: { onSessionDeleted }
});
const sessionId = 'instance-test-abc123-uuid-deleted-test';
// Create and delete session
engine.restoreSession(sessionId, testContext);
const result = engine.deleteSession(sessionId);
// Deletion should succeed
expect(result).toBe(true);
expect(engine.getActiveSessions()).not.toContain(sessionId);
});
it('should not configure onSessionDeleted for non-existent session', () => {
const onSessionDeleted = vi.fn();
engine = new N8NMCPEngine({
sessionEvents: { onSessionDeleted }
});
// Try to delete non-existent session
const result = engine.deleteSession('non-existent-session-id');
// Should return false (session not found)
expect(result).toBe(false);
});
it('should delete session successfully even with handler error', () => {
const errorHandler = vi.fn(() => {
throw new Error('Deletion event error');
});
engine = new N8NMCPEngine({
sessionEvents: { onSessionDeleted: errorHandler }
});
const sessionId = 'instance-test-abc123-uuid-delete-error-test';
// Create session
engine.restoreSession(sessionId, testContext);
// Delete should succeed despite handler error
const deleted = engine.deleteSession(sessionId);
expect(deleted).toBe(true);
// Session should still be deleted
expect(engine.getActiveSessions()).not.toContain(sessionId);
});
});
describe('Multiple events configuration', () => {
it('should support multiple events configured together', () => {
const onSessionCreated = vi.fn();
const onSessionDeleted = vi.fn();
engine = new N8NMCPEngine({
sessionEvents: {
onSessionCreated,
onSessionDeleted
}
});
const sessionId = 'instance-test-abc123-uuid-multi-event-test';
// Create session
engine.restoreSession(sessionId, testContext);
expect(engine.getActiveSessions()).toContain(sessionId);
// Delete session
engine.deleteSession(sessionId);
expect(engine.getActiveSessions()).not.toContain(sessionId);
});
it('should handle mix of sync and async handlers', () => {
const syncHandler = vi.fn();
const asyncHandler = vi.fn(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
});
engine = new N8NMCPEngine({
sessionEvents: {
onSessionCreated: syncHandler,
onSessionDeleted: asyncHandler
}
});
const sessionId = 'instance-test-abc123-uuid-mixed-handlers';
// Create session
const startTime = Date.now();
engine.restoreSession(sessionId, testContext);
const createTime = Date.now();
// Should not block for async handler
expect(createTime - startTime).toBeLessThan(50);
// Delete session
engine.deleteSession(sessionId);
const deleteTime = Date.now();
// Should not block for async handler
expect(deleteTime - createTime).toBeLessThan(50);
});
});
describe('Event handler error behavior', () => {
it('should not propagate errors from event handlers to caller', () => {
const errorHandler = vi.fn(() => {
throw new Error('Test error');
});
engine = new N8NMCPEngine({
sessionEvents: {
onSessionCreated: errorHandler
}
});
const sessionId = 'instance-test-abc123-uuid-no-propagate';
// Should not throw (non-blocking error handling)
expect(() => {
engine.restoreSession(sessionId, testContext);
}).not.toThrow();
// Session was created successfully
expect(engine.getActiveSessions()).toContain(sessionId);
});
it('should allow operations to complete if event handler fails', () => {
const errorHandler = vi.fn(() => {
throw new Error('Handler error');
});
engine = new N8NMCPEngine({
sessionEvents: {
onSessionDeleted: errorHandler
}
});
const sessionId = 'instance-test-abc123-uuid-continue-on-error';
engine.restoreSession(sessionId, testContext);
// Delete should succeed despite handler error
const result = engine.deleteSession(sessionId);
expect(result).toBe(true);
// Session should be deleted
expect(engine.getActiveSessions()).not.toContain(sessionId);
});
});
describe('Event handler with metadata', () => {
it('should configure handlers with metadata support', () => {
const onSessionCreated = vi.fn();
engine = new N8NMCPEngine({
sessionEvents: { onSessionCreated }
});
const sessionId = 'instance-test-abc123-uuid-metadata-test';
const contextWithMetadata = {
...testContext,
metadata: {
userId: 'user-456',
tier: 'enterprise',
region: 'us-east-1'
}
};
engine.restoreSession(sessionId, contextWithMetadata);
// Session created successfully
expect(engine.getActiveSessions()).toContain(sessionId);
// State includes metadata
const state = engine.getSessionState(sessionId);
expect(state?.metadata).toEqual({
userId: 'user-456',
tier: 'enterprise',
region: 'us-east-1'
});
});
});
describe('Configuration validation', () => {
it('should accept empty sessionEvents object', () => {
expect(() => {
engine = new N8NMCPEngine({
sessionEvents: {}
});
}).not.toThrow();
});
it('should accept undefined sessionEvents', () => {
expect(() => {
engine = new N8NMCPEngine({
sessionEvents: undefined
});
}).not.toThrow();
});
it('should work without sessionEvents configured', () => {
engine = new N8NMCPEngine();
const sessionId = 'instance-test-abc123-uuid-no-events';
// Should work normally
engine.restoreSession(sessionId, testContext);
expect(engine.getActiveSessions()).toContain(sessionId);
engine.deleteSession(sessionId);
expect(engine.getActiveSessions()).not.toContain(sessionId);
});
});
});

View File

@@ -1,349 +0,0 @@
/**
* Unit tests for Session Management API (Phase 2 - REQ-5)
* Tests the public API methods for session management in v2.19.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { N8NMCPEngine } from '../../src/mcp-engine';
import { InstanceContext } from '../../src/types/instance-context';
describe('Session Management API (Phase 2 - REQ-5)', () => {
let engine: N8NMCPEngine;
const testContext: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'test-instance'
};
beforeEach(() => {
// Set required AUTH_TOKEN environment variable for testing
process.env.AUTH_TOKEN = 'test-token-for-session-management-testing-32chars';
// Create engine with session restoration disabled for these tests
engine = new N8NMCPEngine({
sessionTimeout: 30 * 60 * 1000 // 30 minutes
});
});
describe('getActiveSessions()', () => {
it('should return empty array when no sessions exist', () => {
const sessionIds = engine.getActiveSessions();
expect(sessionIds).toEqual([]);
});
it('should return session IDs after session creation via restoreSession', () => {
// Create session using direct API (not through HTTP request)
const sessionId = 'instance-test-abc123-uuid-session-test-1';
engine.restoreSession(sessionId, testContext);
const sessionIds = engine.getActiveSessions();
expect(sessionIds.length).toBe(1);
expect(sessionIds).toContain(sessionId);
});
it('should return multiple session IDs when multiple sessions exist', () => {
// Create multiple sessions using direct API
const sessions = [
{ id: 'instance-test1-abc123-uuid-session-1', context: { ...testContext, instanceId: 'instance-1' } },
{ id: 'instance-test2-abc123-uuid-session-2', context: { ...testContext, instanceId: 'instance-2' } }
];
sessions.forEach(({ id, context }) => {
engine.restoreSession(id, context);
});
const sessionIds = engine.getActiveSessions();
expect(sessionIds.length).toBe(2);
expect(sessionIds).toContain(sessions[0].id);
expect(sessionIds).toContain(sessions[1].id);
});
});
describe('getSessionState()', () => {
it('should return null for non-existent session', () => {
const state = engine.getSessionState('non-existent-session-id');
expect(state).toBeNull();
});
it('should return session state for existing session', () => {
// Create a session using direct API
const sessionId = 'instance-test-abc123-uuid-session-state-test';
engine.restoreSession(sessionId, testContext);
const state = engine.getSessionState(sessionId);
expect(state).not.toBeNull();
expect(state).toMatchObject({
sessionId: sessionId,
instanceContext: expect.objectContaining({
n8nApiUrl: testContext.n8nApiUrl,
n8nApiKey: testContext.n8nApiKey,
instanceId: testContext.instanceId
}),
createdAt: expect.any(Date),
lastAccess: expect.any(Date),
expiresAt: expect.any(Date)
});
});
it('should include metadata in session state if available', () => {
const contextWithMetadata: InstanceContext = {
...testContext,
metadata: { userId: 'user-123', tier: 'premium' }
};
const sessionId = 'instance-test-abc123-uuid-metadata-test';
engine.restoreSession(sessionId, contextWithMetadata);
const state = engine.getSessionState(sessionId);
expect(state?.metadata).toEqual({ userId: 'user-123', tier: 'premium' });
});
it('should calculate correct expiration time', () => {
const sessionId = 'instance-test-abc123-uuid-expiry-test';
engine.restoreSession(sessionId, testContext);
const state = engine.getSessionState(sessionId);
expect(state).not.toBeNull();
if (state) {
const expectedExpiry = new Date(state.lastAccess.getTime() + 30 * 60 * 1000);
const actualExpiry = state.expiresAt;
// Allow 1 second difference for test timing
expect(Math.abs(actualExpiry.getTime() - expectedExpiry.getTime())).toBeLessThan(1000);
}
});
});
describe('getAllSessionStates()', () => {
it('should return empty array when no sessions exist', () => {
const states = engine.getAllSessionStates();
expect(states).toEqual([]);
});
it('should return all session states', () => {
// Create two sessions using direct API
const session1Id = 'instance-test1-abc123-uuid-all-states-1';
const session2Id = 'instance-test2-abc123-uuid-all-states-2';
engine.restoreSession(session1Id, {
...testContext,
instanceId: 'instance-1'
});
engine.restoreSession(session2Id, {
...testContext,
instanceId: 'instance-2'
});
const states = engine.getAllSessionStates();
expect(states.length).toBe(2);
expect(states[0]).toMatchObject({
sessionId: expect.any(String),
instanceContext: expect.objectContaining({
n8nApiUrl: testContext.n8nApiUrl
}),
createdAt: expect.any(Date),
lastAccess: expect.any(Date),
expiresAt: expect.any(Date)
});
});
it('should filter out sessions without state', () => {
// Create session using direct API
const sessionId = 'instance-test-abc123-uuid-filter-test';
engine.restoreSession(sessionId, testContext);
// Get states
const states = engine.getAllSessionStates();
expect(states.length).toBe(1);
// All returned states should be non-null
states.forEach(state => {
expect(state).not.toBeNull();
});
});
});
describe('restoreSession()', () => {
it('should create a new session with provided ID and context', () => {
const sessionId = 'instance-test-abc123-uuid-test-session-id';
const result = engine.restoreSession(sessionId, testContext);
expect(result).toBe(true);
expect(engine.getActiveSessions()).toContain(sessionId);
});
it('should be idempotent - return true for existing session', () => {
const sessionId = 'instance-test-abc123-uuid-test-session-id2';
// First restoration
const result1 = engine.restoreSession(sessionId, testContext);
expect(result1).toBe(true);
// Second restoration with same ID
const result2 = engine.restoreSession(sessionId, testContext);
expect(result2).toBe(true);
// Should still only have one session
const sessionIds = engine.getActiveSessions();
expect(sessionIds.filter(id => id === sessionId).length).toBe(1);
});
it('should return false for invalid session ID format', () => {
const invalidSessionIds = [
'', // Empty string
'a'.repeat(101), // Too long (101 chars, exceeds max)
"'; DROP TABLE sessions--", // SQL injection attempt (invalid characters: ', ;, space)
'../../../etc/passwd', // Path traversal attempt (invalid characters: ., /)
'has spaces here', // Invalid character (space)
'special@chars#here' // Invalid characters (@, #)
];
invalidSessionIds.forEach(sessionId => {
const result = engine.restoreSession(sessionId, testContext);
expect(result).toBe(false);
});
});
it('should accept short session IDs (relaxed for MCP proxy compatibility)', () => {
const validShortIds = [
'short', // 5 chars - now valid
'a', // 1 char - now valid
'only-nineteen-chars', // 19 chars - now valid
'12345' // 5 digit ID - now valid
];
validShortIds.forEach(sessionId => {
const result = engine.restoreSession(sessionId, testContext);
expect(result).toBe(true);
expect(engine.getActiveSessions()).toContain(sessionId);
});
});
it('should return false for invalid instance context', () => {
const sessionId = 'instance-test-abc123-uuid-test-session-id3';
const invalidContext = {
n8nApiUrl: 'not-a-valid-url', // Invalid URL
n8nApiKey: 'test-key',
instanceId: 'test'
} as any;
const result = engine.restoreSession(sessionId, invalidContext);
expect(result).toBe(false);
});
it('should create session that can be retrieved with getSessionState', () => {
const sessionId = 'instance-test-abc123-uuid-test-session-id4';
engine.restoreSession(sessionId, testContext);
const state = engine.getSessionState(sessionId);
expect(state).not.toBeNull();
expect(state?.sessionId).toBe(sessionId);
expect(state?.instanceContext).toEqual(testContext);
});
});
describe('deleteSession()', () => {
it('should return false for non-existent session', () => {
const result = engine.deleteSession('non-existent-session-id');
expect(result).toBe(false);
});
it('should delete existing session and return true', () => {
// Create a session using direct API
const sessionId = 'instance-test-abc123-uuid-delete-test';
engine.restoreSession(sessionId, testContext);
// Delete the session
const result = engine.deleteSession(sessionId);
expect(result).toBe(true);
// Session should no longer exist
expect(engine.getActiveSessions()).not.toContain(sessionId);
expect(engine.getSessionState(sessionId)).toBeNull();
});
it('should return false when trying to delete already deleted session', () => {
// Create and delete session using direct API
const sessionId = 'instance-test-abc123-uuid-double-delete-test';
engine.restoreSession(sessionId, testContext);
engine.deleteSession(sessionId);
// Try to delete again
const result = engine.deleteSession(sessionId);
expect(result).toBe(false);
});
});
describe('Integration workflows', () => {
it('should support periodic backup workflow', () => {
// Create multiple sessions using direct API
for (let i = 0; i < 3; i++) {
const sessionId = `instance-test${i}-abc123-uuid-backup-${i}`;
engine.restoreSession(sessionId, {
...testContext,
instanceId: `instance-${i}`
});
}
// Simulate periodic backup
const states = engine.getAllSessionStates();
expect(states.length).toBe(3);
// Each state should be serializable
states.forEach(state => {
const serialized = JSON.stringify(state);
expect(serialized).toBeTruthy();
const deserialized = JSON.parse(serialized);
expect(deserialized.sessionId).toBe(state.sessionId);
});
});
it('should support bulk restore workflow', () => {
const sessionData = [
{ sessionId: 'instance-test1-abc123-uuid-bulk-session-1', context: { ...testContext, instanceId: 'user-1' } },
{ sessionId: 'instance-test2-abc123-uuid-bulk-session-2', context: { ...testContext, instanceId: 'user-2' } },
{ sessionId: 'instance-test3-abc123-uuid-bulk-session-3', context: { ...testContext, instanceId: 'user-3' } }
];
// Restore all sessions
for (const { sessionId, context } of sessionData) {
const restored = engine.restoreSession(sessionId, context);
expect(restored).toBe(true);
}
// Verify all sessions exist
const sessionIds = engine.getActiveSessions();
expect(sessionIds.length).toBe(3);
sessionData.forEach(({ sessionId }) => {
expect(sessionIds).toContain(sessionId);
});
});
it('should support session lifecycle workflow (create → get → delete)', () => {
// 1. Create session using direct API
const sessionId = 'instance-test-abc123-uuid-lifecycle-test';
engine.restoreSession(sessionId, testContext);
// 2. Get session state
const state = engine.getSessionState(sessionId);
expect(state).not.toBeNull();
// 3. Simulate saving to database (serialization test)
const serialized = JSON.stringify(state);
expect(serialized).toBeTruthy();
// 4. Delete session
const deleted = engine.deleteSession(sessionId);
expect(deleted).toBe(true);
// 5. Verify deletion
expect(engine.getSessionState(sessionId)).toBeNull();
expect(engine.getActiveSessions()).not.toContain(sessionId);
});
});
});

View File

@@ -1,400 +0,0 @@
/**
* Unit tests for Session Restoration Retry Policy (Phase 4 - REQ-7)
* Tests retry logic for failed session restoration attempts
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { N8NMCPEngine } from '../../src/mcp-engine';
import { InstanceContext } from '../../src/types/instance-context';
describe('Session Restoration Retry Policy (Phase 4 - REQ-7)', () => {
const testContext: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'test-instance'
};
beforeEach(() => {
// Set required AUTH_TOKEN environment variable for testing
process.env.AUTH_TOKEN = 'test-token-for-session-restoration-retry-testing-32chars';
vi.clearAllMocks();
});
describe('Default behavior (no retries)', () => {
it('should have 0 retries by default (opt-in)', async () => {
let callCount = 0;
const failingHook = vi.fn(async () => {
callCount++;
throw new Error('Database connection failed');
});
const engine = new N8NMCPEngine({
onSessionNotFound: failingHook
// No sessionRestorationRetries specified - should default to 0
});
// Note: Testing retry behavior requires HTTP request simulation
// This is tested in integration tests
// Here we verify configuration is accepted
expect(() => {
const sessionId = 'instance-test-abc123-uuid-default-retry';
engine.restoreSession(sessionId, testContext);
}).not.toThrow();
});
it('should throw immediately on error with 0 retries', () => {
const failingHook = vi.fn(async () => {
throw new Error('Test error');
});
const engine = new N8NMCPEngine({
onSessionNotFound: failingHook,
sessionRestorationRetries: 0 // Explicit 0 retries
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
});
describe('Retry configuration', () => {
it('should accept custom retry count', () => {
const hook = vi.fn(async () => testContext);
const engine = new N8NMCPEngine({
onSessionNotFound: hook,
sessionRestorationRetries: 3
});
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
it('should accept custom retry delay', () => {
const hook = vi.fn(async () => testContext);
const engine = new N8NMCPEngine({
onSessionNotFound: hook,
sessionRestorationRetries: 2,
sessionRestorationRetryDelay: 200 // 200ms delay
});
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
it('should use default delay of 100ms if not specified', () => {
const hook = vi.fn(async () => testContext);
const engine = new N8NMCPEngine({
onSessionNotFound: hook,
sessionRestorationRetries: 2
// sessionRestorationRetryDelay not specified - should default to 100ms
});
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
});
describe('Error classification', () => {
it('should configure retry for transient errors', () => {
let attemptCount = 0;
const failTwiceThenSucceed = vi.fn(async () => {
attemptCount++;
if (attemptCount < 3) {
throw new Error('Transient error');
}
return testContext;
});
const engine = new N8NMCPEngine({
onSessionNotFound: failTwiceThenSucceed,
sessionRestorationRetries: 3
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
it('should not configure retry for timeout errors', () => {
const timeoutHook = vi.fn(async () => {
const error = new Error('Timeout error');
error.name = 'TimeoutError';
throw error;
});
const engine = new N8NMCPEngine({
onSessionNotFound: timeoutHook,
sessionRestorationRetries: 3,
sessionRestorationTimeout: 100
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
});
describe('Timeout interaction', () => {
it('should configure overall timeout for all retry attempts', () => {
const slowHook = vi.fn(async () => {
await new Promise(resolve => setTimeout(resolve, 200));
return testContext;
});
const engine = new N8NMCPEngine({
onSessionNotFound: slowHook,
sessionRestorationRetries: 3,
sessionRestorationTimeout: 500 // 500ms total for all attempts
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
it('should use default timeout of 5000ms if not specified', () => {
const hook = vi.fn(async () => testContext);
const engine = new N8NMCPEngine({
onSessionNotFound: hook,
sessionRestorationRetries: 2
// sessionRestorationTimeout not specified - should default to 5000ms
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
});
describe('Success scenarios', () => {
it('should succeed on first attempt if hook succeeds', () => {
const successHook = vi.fn(async () => testContext);
const engine = new N8NMCPEngine({
onSessionNotFound: successHook,
sessionRestorationRetries: 3
});
// Should succeed
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
it('should succeed after retry if hook eventually succeeds', () => {
let attemptCount = 0;
const retryThenSucceed = vi.fn(async () => {
attemptCount++;
if (attemptCount === 1) {
throw new Error('First attempt failed');
}
return testContext;
});
const engine = new N8NMCPEngine({
onSessionNotFound: retryThenSucceed,
sessionRestorationRetries: 2
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
});
describe('Hook validation', () => {
it('should validate context returned by hook after retry', () => {
let attemptCount = 0;
const invalidAfterRetry = vi.fn(async () => {
attemptCount++;
if (attemptCount === 1) {
throw new Error('First attempt failed');
}
// Return invalid context after retry
return {
n8nApiUrl: 'not-a-valid-url', // Invalid URL
n8nApiKey: 'test-key',
instanceId: 'test'
} as any;
});
const engine = new N8NMCPEngine({
onSessionNotFound: invalidAfterRetry,
sessionRestorationRetries: 2
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
it('should handle null return from hook after retry', () => {
let attemptCount = 0;
const nullAfterRetry = vi.fn(async () => {
attemptCount++;
if (attemptCount === 1) {
throw new Error('First attempt failed');
}
return null; // Session not found after retry
});
const engine = new N8NMCPEngine({
onSessionNotFound: nullAfterRetry,
sessionRestorationRetries: 2
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
});
describe('Edge cases', () => {
it('should handle exactly max retries configuration', () => {
let attemptCount = 0;
const failExactlyMaxTimes = vi.fn(async () => {
attemptCount++;
if (attemptCount <= 2) {
throw new Error('Failing');
}
return testContext;
});
const engine = new N8NMCPEngine({
onSessionNotFound: failExactlyMaxTimes,
sessionRestorationRetries: 2 // Will succeed on 3rd attempt (0, 1, 2 retries)
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
it('should handle zero delay between retries', () => {
const hook = vi.fn(async () => testContext);
const engine = new N8NMCPEngine({
onSessionNotFound: hook,
sessionRestorationRetries: 3,
sessionRestorationRetryDelay: 0 // No delay
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
it('should handle very short timeout', () => {
const hook = vi.fn(async () => testContext);
const engine = new N8NMCPEngine({
onSessionNotFound: hook,
sessionRestorationRetries: 3,
sessionRestorationTimeout: 1 // 1ms timeout
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
});
describe('Integration with lifecycle events', () => {
it('should emit onSessionRestored after successful retry', () => {
let attemptCount = 0;
const retryThenSucceed = vi.fn(async () => {
attemptCount++;
if (attemptCount === 1) {
throw new Error('First attempt failed');
}
return testContext;
});
const onSessionRestored = vi.fn();
const engine = new N8NMCPEngine({
onSessionNotFound: retryThenSucceed,
sessionRestorationRetries: 2,
sessionEvents: {
onSessionRestored
}
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
it('should not emit events if all retries fail', () => {
const alwaysFail = vi.fn(async () => {
throw new Error('Always fails');
});
const onSessionRestored = vi.fn();
const engine = new N8NMCPEngine({
onSessionNotFound: alwaysFail,
sessionRestorationRetries: 2,
sessionEvents: {
onSessionRestored
}
});
// Configuration accepted
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
});
describe('Backward compatibility', () => {
it('should work without retry configuration (backward compatible)', () => {
const hook = vi.fn(async () => testContext);
const engine = new N8NMCPEngine({
onSessionNotFound: hook
// No retry configuration - should work as before
});
// Should work
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
it('should work with only restoration hook configured', () => {
const hook = vi.fn(async () => testContext);
const engine = new N8NMCPEngine({
onSessionNotFound: hook,
sessionRestorationTimeout: 5000
// No retry configuration
});
// Should work
expect(() => {
engine.restoreSession('test-session', testContext);
}).not.toThrow();
});
});
});

View File

@@ -1,551 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SingleSessionHTTPServer } from '../../src/http-server-single-session';
import { InstanceContext } from '../../src/types/instance-context';
import { SessionRestoreHook } from '../../src/types/session-restoration';
// 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
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) => {
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
};
if (options?.sessionIdGenerator) {
const sessionId = options.sessionIdGenerator();
mockTransport.sessionId = sessionId;
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', () => {
class MockN8NDocumentationMCPServer {
connect = vi.fn().mockResolvedValue(undefined);
}
return {
N8NDocumentationMCPServer: MockN8NDocumentationMCPServer
};
});
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.19.0'
}));
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('Session Restoration (Phase 1 - REQ-1, REQ-2, REQ-8)', () => {
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 = [];
});
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: 'POST',
path: '/mcp',
url: '/mcp',
originalUrl: '/mcp',
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('REQ-8: Security-Hardened Session ID Validation', () => {
it('should accept valid UUIDv4 session IDs', () => {
server = new SingleSessionHTTPServer();
const validUUIDs = [
'550e8400-e29b-41d4-a716-446655440000',
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'a1b2c3d4-e5f6-4789-abcd-1234567890ab'
];
for (const sessionId of validUUIDs) {
expect((server as any).isValidSessionId(sessionId)).toBe(true);
}
});
it('should accept multi-tenant instance session IDs', () => {
server = new SingleSessionHTTPServer();
const multiTenantIds = [
'instance-user123-abc-550e8400-e29b-41d4-a716-446655440000',
'instance-tenant456-xyz-f47ac10b-58cc-4372-a567-0e02b2c3d479'
];
for (const sessionId of multiTenantIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(true);
}
});
it('should reject session IDs with SQL injection patterns', () => {
server = new SingleSessionHTTPServer();
const sqlInjectionIds = [
"'; DROP TABLE sessions; --",
"1' OR '1'='1",
"admin'--",
"1'; DELETE FROM sessions WHERE '1'='1"
];
for (const sessionId of sqlInjectionIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(false);
}
});
it('should reject session IDs with NoSQL injection patterns', () => {
server = new SingleSessionHTTPServer();
const nosqlInjectionIds = [
'{"$ne": null}',
'{"$gt": ""}',
'{$where: "1==1"}',
'[$regex]'
];
for (const sessionId of nosqlInjectionIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(false);
}
});
it('should reject session IDs with path traversal attempts', () => {
server = new SingleSessionHTTPServer();
const pathTraversalIds = [
'../../../etc/passwd',
'..\\..\\..\\windows\\system32',
'session/../admin',
'session/./../../config'
];
for (const sessionId of pathTraversalIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(false);
}
});
it('should accept short session IDs (relaxed for MCP proxy compatibility)', () => {
server = new SingleSessionHTTPServer();
// Short session IDs are now accepted for MCP proxy compatibility
// Security is maintained via character whitelist and max length
const shortIds = [
'a',
'ab',
'123',
'12345',
'short-id'
];
for (const sessionId of shortIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(true);
}
});
it('should reject session IDs that are too long (DoS protection)', () => {
server = new SingleSessionHTTPServer();
const tooLongId = 'a'.repeat(101); // Maximum is 100 chars
expect((server as any).isValidSessionId(tooLongId)).toBe(false);
});
it('should reject empty or null session IDs', () => {
server = new SingleSessionHTTPServer();
expect((server as any).isValidSessionId('')).toBe(false);
expect((server as any).isValidSessionId(null)).toBe(false);
expect((server as any).isValidSessionId(undefined)).toBe(false);
});
it('should reject session IDs with special characters', () => {
server = new SingleSessionHTTPServer();
const specialCharIds = [
'session<script>alert(1)</script>',
'session!@#$%^&*()',
'session\x00null-byte',
'session\r\nnewline'
];
for (const sessionId of specialCharIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(false);
}
});
});
describe('REQ-2: Idempotent Session Creation', () => {
it('should return same session ID for multiple concurrent createSession calls', async () => {
const mockContext: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'tenant-123'
};
server = new SingleSessionHTTPServer();
const sessionId = 'instance-tenant123-abc-550e8400-e29b-41d4-a716-446655440000';
// Call createSession multiple times with same session ID
const id1 = (server as any).createSession(mockContext, sessionId);
const id2 = (server as any).createSession(mockContext, sessionId);
const id3 = (server as any).createSession(mockContext, sessionId);
// All calls should return the same session ID (idempotent)
expect(id1).toBe(sessionId);
expect(id2).toBe(sessionId);
expect(id3).toBe(sessionId);
// NOTE: Transport creation is async via callback - tested in integration tests
});
it('should skip session creation if session already exists', async () => {
const mockContext: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'tenant-123'
};
server = new SingleSessionHTTPServer();
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
// Create session first time
(server as any).createSession(mockContext, sessionId);
const transport1 = (server as any).transports[sessionId];
// Try to create again
(server as any).createSession(mockContext, sessionId);
const transport2 = (server as any).transports[sessionId];
// Should be the same transport instance
expect(transport1).toBe(transport2);
});
it('should validate session ID format when provided externally', async () => {
const mockContext: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'tenant-123'
};
server = new SingleSessionHTTPServer();
const invalidSessionId = "'; DROP TABLE sessions; --";
expect(() => {
(server as any).createSession(mockContext, invalidSessionId);
}).toThrow('Invalid session ID format');
});
});
describe('REQ-1: Session Restoration Hook Configuration', () => {
it('should store restoration hook when provided', () => {
const mockHook: SessionRestoreHook = vi.fn().mockResolvedValue({
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'tenant-123'
});
server = new SingleSessionHTTPServer({
onSessionNotFound: mockHook,
sessionRestorationTimeout: 5000
});
// Verify hook is stored
expect((server as any).onSessionNotFound).toBe(mockHook);
expect((server as any).sessionRestorationTimeout).toBe(5000);
});
it('should work without restoration hook (backward compatible)', () => {
server = new SingleSessionHTTPServer();
// Verify hook is not configured
expect((server as any).onSessionNotFound).toBeUndefined();
});
// NOTE: Full restoration flow tests (success, failure, timeout, validation)
// are in tests/integration/session-persistence.test.ts which tests the complete
// end-to-end flow with real HTTP requests
});
describe('Backwards Compatibility', () => {
it('should use default timeout when not specified', () => {
server = new SingleSessionHTTPServer({
onSessionNotFound: vi.fn()
});
expect((server as any).sessionRestorationTimeout).toBe(5000);
});
it('should use custom timeout when specified', () => {
server = new SingleSessionHTTPServer({
onSessionNotFound: vi.fn(),
sessionRestorationTimeout: 10000
});
expect((server as any).sessionRestorationTimeout).toBe(10000);
});
it('should work without any restoration options', () => {
server = new SingleSessionHTTPServer();
expect((server as any).onSessionNotFound).toBeUndefined();
expect((server as any).sessionRestorationTimeout).toBe(5000);
});
});
describe('Timeout Utility Method', () => {
it('should reject after specified timeout', async () => {
server = new SingleSessionHTTPServer();
const timeoutPromise = (server as any).timeout(100);
await expect(timeoutPromise).rejects.toThrow('Operation timed out after 100ms');
});
it('should create TimeoutError', async () => {
server = new SingleSessionHTTPServer();
try {
await (server as any).timeout(50);
expect.fail('Should have thrown TimeoutError');
} catch (error: any) {
expect(error.name).toBe('TimeoutError');
expect(error.message).toContain('timed out');
}
});
});
describe('Session ID Generation', () => {
it('should generate valid session IDs', () => {
// Set environment for multi-tenant mode
process.env.ENABLE_MULTI_TENANT = 'true';
process.env.MULTI_TENANT_SESSION_STRATEGY = 'instance';
server = new SingleSessionHTTPServer();
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'tenant-123'
};
const sessionId = (server as any).generateSessionId(context);
// Should generate instance-prefixed ID in multi-tenant mode
expect(sessionId).toContain('instance-');
expect((server as any).isValidSessionId(sessionId)).toBe(true);
// Clean up env
delete process.env.ENABLE_MULTI_TENANT;
delete process.env.MULTI_TENANT_SESSION_STRATEGY;
});
it('should generate standard UUIDs when not in multi-tenant mode', () => {
// Ensure multi-tenant mode is disabled
delete process.env.ENABLE_MULTI_TENANT;
server = new SingleSessionHTTPServer();
const sessionId = (server as any).generateSessionId();
// Should be a UUID format (mocked in tests but should be non-empty string with hyphens)
expect(sessionId).toBeTruthy();
expect(typeof sessionId).toBe('string');
expect(sessionId.length).toBeGreaterThan(20); // At minimum should be longer than minimum session ID length
expect(sessionId).toContain('-');
// NOTE: In tests, UUID is mocked so it may not pass strict validation
// In production, generateSessionId uses real uuid.v4() which generates valid UUIDs
});
});
});