mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-24 03:13:07 +00:00
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 commitsfe13091through1d34ad8. Restores commit4566253(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:
committed by
GitHub
parent
fe1309151a
commit
8d20c64f5c
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user