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