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

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

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

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

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

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

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

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

View File

@@ -631,16 +631,15 @@ describe('HTTP Server Session Management', () => {
describe('Transport Management', () => {
it('should handle transport cleanup on close', async () => {
server = new SingleSessionHTTPServer();
// Test the transport cleanup mechanism by calling removeSession directly
// Test the transport cleanup mechanism by setting up a transport with onclose
const sessionId = 'test-session-id-1234-5678-9012-345678901234';
const mockTransport = {
close: vi.fn().mockResolvedValue(undefined),
sessionId,
onclose: undefined as (() => void) | undefined,
onerror: undefined as ((error: Error) => void) | undefined
onclose: null as (() => void) | null
};
(server as any).transports[sessionId] = mockTransport;
(server as any).servers[sessionId] = {};
(server as any).sessionMetadata[sessionId] = {
@@ -648,16 +647,18 @@ describe('HTTP Server Session Management', () => {
createdAt: new Date()
};
// Directly call removeSession to test cleanup behavior
await (server as any).removeSession(sessionId, 'transport_closed');
// Set up the onclose handler like the real implementation would
mockTransport.onclose = () => {
(server as any).removeSession(sessionId, 'transport_closed');
};
// Verify cleanup completed
// Simulate transport close
if (mockTransport.onclose) {
await mockTransport.onclose();
}
// Verify cleanup was triggered
expect((server as any).transports[sessionId]).toBeUndefined();
expect((server as any).servers[sessionId]).toBeUndefined();
expect((server as any).sessionMetadata[sessionId]).toBeUndefined();
expect(mockTransport.close).toHaveBeenCalled();
expect(mockTransport.onclose).toBeUndefined();
expect(mockTransport.onerror).toBeUndefined();
});
it('should handle multiple concurrent sessions', async () => {

View File

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

View File

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

View File

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

View File

@@ -1,551 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SingleSessionHTTPServer } from '../../src/http-server-single-session';
import { InstanceContext } from '../../src/types/instance-context';
import { SessionRestoreHook } from '../../src/types/session-restoration';
// Mock dependencies
vi.mock('../../src/utils/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn()
}
}));
vi.mock('dotenv');
// Mock UUID generation to make tests predictable
vi.mock('uuid', () => ({
v4: vi.fn(() => 'test-session-id-1234-5678-9012-345678901234')
}));
// Mock transport
vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
StreamableHTTPServerTransport: vi.fn().mockImplementation((options: any) => {
const mockTransport = {
handleRequest: vi.fn().mockImplementation(async (req: any, res: any, body?: any) => {
if (body && body.method === 'initialize') {
res.setHeader('Mcp-Session-Id', mockTransport.sessionId || 'test-session-id');
}
res.status(200).json({
jsonrpc: '2.0',
result: { success: true },
id: body?.id || 1
});
}),
close: vi.fn().mockResolvedValue(undefined),
sessionId: null as string | null,
onclose: null as (() => void) | null
};
if (options?.sessionIdGenerator) {
const sessionId = options.sessionIdGenerator();
mockTransport.sessionId = sessionId;
if (options.onsessioninitialized) {
setTimeout(() => {
options.onsessioninitialized(sessionId);
}, 0);
}
}
return mockTransport;
})
}));
vi.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
SSEServerTransport: vi.fn().mockImplementation(() => ({
close: vi.fn().mockResolvedValue(undefined)
}))
}));
vi.mock('../../src/mcp/server', () => {
class MockN8NDocumentationMCPServer {
connect = vi.fn().mockResolvedValue(undefined);
}
return {
N8NDocumentationMCPServer: MockN8NDocumentationMCPServer
};
});
const mockConsoleManager = {
wrapOperation: vi.fn().mockImplementation(async (fn: () => Promise<any>) => {
return await fn();
})
};
vi.mock('../../src/utils/console-manager', () => ({
ConsoleManager: vi.fn(() => mockConsoleManager)
}));
vi.mock('../../src/utils/url-detector', () => ({
getStartupBaseUrl: vi.fn((host: string, port: number) => `http://localhost:${port || 3000}`),
formatEndpointUrls: vi.fn((baseUrl: string) => ({
health: `${baseUrl}/health`,
mcp: `${baseUrl}/mcp`
})),
detectBaseUrl: vi.fn((req: any, host: string, port: number) => `http://localhost:${port || 3000}`)
}));
vi.mock('../../src/utils/version', () => ({
PROJECT_VERSION: '2.19.0'
}));
vi.mock('@modelcontextprotocol/sdk/types.js', () => ({
isInitializeRequest: vi.fn((request: any) => {
return request && request.method === 'initialize';
})
}));
// Create handlers storage for Express mock
const mockHandlers: { [key: string]: any[] } = {
get: [],
post: [],
delete: [],
use: []
};
// Mock Express
vi.mock('express', () => {
const mockExpressApp = {
get: vi.fn((path: string, ...handlers: any[]) => {
mockHandlers.get.push({ path, handlers });
return mockExpressApp;
}),
post: vi.fn((path: string, ...handlers: any[]) => {
mockHandlers.post.push({ path, handlers });
return mockExpressApp;
}),
delete: vi.fn((path: string, ...handlers: any[]) => {
mockHandlers.delete.push({ path, handlers });
return mockExpressApp;
}),
use: vi.fn((handler: any) => {
mockHandlers.use.push(handler);
return mockExpressApp;
}),
set: vi.fn(),
listen: vi.fn((port: number, host: string, callback?: () => void) => {
if (callback) callback();
return {
on: vi.fn(),
close: vi.fn((cb: () => void) => cb()),
address: () => ({ port: 3000 })
};
})
};
interface ExpressMock {
(): typeof mockExpressApp;
json(): (req: any, res: any, next: any) => void;
}
const expressMock = vi.fn(() => mockExpressApp) as unknown as ExpressMock;
expressMock.json = vi.fn(() => (req: any, res: any, next: any) => {
req.body = req.body || {};
next();
});
return {
default: expressMock,
Request: {},
Response: {},
NextFunction: {}
};
});
describe('Session Restoration (Phase 1 - REQ-1, REQ-2, REQ-8)', () => {
const originalEnv = process.env;
const TEST_AUTH_TOKEN = 'test-auth-token-with-more-than-32-characters';
let server: SingleSessionHTTPServer;
let consoleLogSpy: any;
let consoleWarnSpy: any;
let consoleErrorSpy: any;
beforeEach(() => {
// Reset environment
process.env = { ...originalEnv };
process.env.AUTH_TOKEN = TEST_AUTH_TOKEN;
process.env.PORT = '0';
process.env.NODE_ENV = 'test';
// Mock console methods
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Clear all mocks and handlers
vi.clearAllMocks();
mockHandlers.get = [];
mockHandlers.post = [];
mockHandlers.delete = [];
mockHandlers.use = [];
});
afterEach(async () => {
// Restore environment
process.env = originalEnv;
// Restore console methods
consoleLogSpy.mockRestore();
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
// Shutdown server if running
if (server) {
await server.shutdown();
server = null as any;
}
});
// Helper functions
function findHandler(method: 'get' | 'post' | 'delete', path: string) {
const routes = mockHandlers[method];
const route = routes.find(r => r.path === path);
return route ? route.handlers[route.handlers.length - 1] : null;
}
function createMockReqRes() {
const headers: { [key: string]: string } = {};
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis(),
setHeader: vi.fn((key: string, value: string) => {
headers[key.toLowerCase()] = value;
}),
sendStatus: vi.fn().mockReturnThis(),
headersSent: false,
finished: false,
statusCode: 200,
getHeader: (key: string) => headers[key.toLowerCase()],
headers
};
const req = {
method: 'POST',
path: '/mcp',
url: '/mcp',
originalUrl: '/mcp',
headers: {} as Record<string, string>,
body: {},
ip: '127.0.0.1',
readable: true,
readableEnded: false,
complete: true,
get: vi.fn((header: string) => (req.headers as Record<string, string>)[header.toLowerCase()])
};
return { req, res };
}
describe('REQ-8: Security-Hardened Session ID Validation', () => {
it('should accept valid UUIDv4 session IDs', () => {
server = new SingleSessionHTTPServer();
const validUUIDs = [
'550e8400-e29b-41d4-a716-446655440000',
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'a1b2c3d4-e5f6-4789-abcd-1234567890ab'
];
for (const sessionId of validUUIDs) {
expect((server as any).isValidSessionId(sessionId)).toBe(true);
}
});
it('should accept multi-tenant instance session IDs', () => {
server = new SingleSessionHTTPServer();
const multiTenantIds = [
'instance-user123-abc-550e8400-e29b-41d4-a716-446655440000',
'instance-tenant456-xyz-f47ac10b-58cc-4372-a567-0e02b2c3d479'
];
for (const sessionId of multiTenantIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(true);
}
});
it('should reject session IDs with SQL injection patterns', () => {
server = new SingleSessionHTTPServer();
const sqlInjectionIds = [
"'; DROP TABLE sessions; --",
"1' OR '1'='1",
"admin'--",
"1'; DELETE FROM sessions WHERE '1'='1"
];
for (const sessionId of sqlInjectionIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(false);
}
});
it('should reject session IDs with NoSQL injection patterns', () => {
server = new SingleSessionHTTPServer();
const nosqlInjectionIds = [
'{"$ne": null}',
'{"$gt": ""}',
'{$where: "1==1"}',
'[$regex]'
];
for (const sessionId of nosqlInjectionIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(false);
}
});
it('should reject session IDs with path traversal attempts', () => {
server = new SingleSessionHTTPServer();
const pathTraversalIds = [
'../../../etc/passwd',
'..\\..\\..\\windows\\system32',
'session/../admin',
'session/./../../config'
];
for (const sessionId of pathTraversalIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(false);
}
});
it('should accept short session IDs (relaxed for MCP proxy compatibility)', () => {
server = new SingleSessionHTTPServer();
// Short session IDs are now accepted for MCP proxy compatibility
// Security is maintained via character whitelist and max length
const shortIds = [
'a',
'ab',
'123',
'12345',
'short-id'
];
for (const sessionId of shortIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(true);
}
});
it('should reject session IDs that are too long (DoS protection)', () => {
server = new SingleSessionHTTPServer();
const tooLongId = 'a'.repeat(101); // Maximum is 100 chars
expect((server as any).isValidSessionId(tooLongId)).toBe(false);
});
it('should reject empty or null session IDs', () => {
server = new SingleSessionHTTPServer();
expect((server as any).isValidSessionId('')).toBe(false);
expect((server as any).isValidSessionId(null)).toBe(false);
expect((server as any).isValidSessionId(undefined)).toBe(false);
});
it('should reject session IDs with special characters', () => {
server = new SingleSessionHTTPServer();
const specialCharIds = [
'session<script>alert(1)</script>',
'session!@#$%^&*()',
'session\x00null-byte',
'session\r\nnewline'
];
for (const sessionId of specialCharIds) {
expect((server as any).isValidSessionId(sessionId)).toBe(false);
}
});
});
describe('REQ-2: Idempotent Session Creation', () => {
it('should return same session ID for multiple concurrent createSession calls', async () => {
const mockContext: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'tenant-123'
};
server = new SingleSessionHTTPServer();
const sessionId = 'instance-tenant123-abc-550e8400-e29b-41d4-a716-446655440000';
// Call createSession multiple times with same session ID
const id1 = (server as any).createSession(mockContext, sessionId);
const id2 = (server as any).createSession(mockContext, sessionId);
const id3 = (server as any).createSession(mockContext, sessionId);
// All calls should return the same session ID (idempotent)
expect(id1).toBe(sessionId);
expect(id2).toBe(sessionId);
expect(id3).toBe(sessionId);
// NOTE: Transport creation is async via callback - tested in integration tests
});
it('should skip session creation if session already exists', async () => {
const mockContext: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'tenant-123'
};
server = new SingleSessionHTTPServer();
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
// Create session first time
(server as any).createSession(mockContext, sessionId);
const transport1 = (server as any).transports[sessionId];
// Try to create again
(server as any).createSession(mockContext, sessionId);
const transport2 = (server as any).transports[sessionId];
// Should be the same transport instance
expect(transport1).toBe(transport2);
});
it('should validate session ID format when provided externally', async () => {
const mockContext: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'tenant-123'
};
server = new SingleSessionHTTPServer();
const invalidSessionId = "'; DROP TABLE sessions; --";
expect(() => {
(server as any).createSession(mockContext, invalidSessionId);
}).toThrow('Invalid session ID format');
});
});
describe('REQ-1: Session Restoration Hook Configuration', () => {
it('should store restoration hook when provided', () => {
const mockHook: SessionRestoreHook = vi.fn().mockResolvedValue({
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'tenant-123'
});
server = new SingleSessionHTTPServer({
onSessionNotFound: mockHook,
sessionRestorationTimeout: 5000
});
// Verify hook is stored
expect((server as any).onSessionNotFound).toBe(mockHook);
expect((server as any).sessionRestorationTimeout).toBe(5000);
});
it('should work without restoration hook (backward compatible)', () => {
server = new SingleSessionHTTPServer();
// Verify hook is not configured
expect((server as any).onSessionNotFound).toBeUndefined();
});
// NOTE: Full restoration flow tests (success, failure, timeout, validation)
// are in tests/integration/session-persistence.test.ts which tests the complete
// end-to-end flow with real HTTP requests
});
describe('Backwards Compatibility', () => {
it('should use default timeout when not specified', () => {
server = new SingleSessionHTTPServer({
onSessionNotFound: vi.fn()
});
expect((server as any).sessionRestorationTimeout).toBe(5000);
});
it('should use custom timeout when specified', () => {
server = new SingleSessionHTTPServer({
onSessionNotFound: vi.fn(),
sessionRestorationTimeout: 10000
});
expect((server as any).sessionRestorationTimeout).toBe(10000);
});
it('should work without any restoration options', () => {
server = new SingleSessionHTTPServer();
expect((server as any).onSessionNotFound).toBeUndefined();
expect((server as any).sessionRestorationTimeout).toBe(5000);
});
});
describe('Timeout Utility Method', () => {
it('should reject after specified timeout', async () => {
server = new SingleSessionHTTPServer();
const timeoutPromise = (server as any).timeout(100);
await expect(timeoutPromise).rejects.toThrow('Operation timed out after 100ms');
});
it('should create TimeoutError', async () => {
server = new SingleSessionHTTPServer();
try {
await (server as any).timeout(50);
expect.fail('Should have thrown TimeoutError');
} catch (error: any) {
expect(error.name).toBe('TimeoutError');
expect(error.message).toContain('timed out');
}
});
});
describe('Session ID Generation', () => {
it('should generate valid session IDs', () => {
// Set environment for multi-tenant mode
process.env.ENABLE_MULTI_TENANT = 'true';
process.env.MULTI_TENANT_SESSION_STRATEGY = 'instance';
server = new SingleSessionHTTPServer();
const context: InstanceContext = {
n8nApiUrl: 'https://test.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'tenant-123'
};
const sessionId = (server as any).generateSessionId(context);
// Should generate instance-prefixed ID in multi-tenant mode
expect(sessionId).toContain('instance-');
expect((server as any).isValidSessionId(sessionId)).toBe(true);
// Clean up env
delete process.env.ENABLE_MULTI_TENANT;
delete process.env.MULTI_TENANT_SESSION_STRATEGY;
});
it('should generate standard UUIDs when not in multi-tenant mode', () => {
// Ensure multi-tenant mode is disabled
delete process.env.ENABLE_MULTI_TENANT;
server = new SingleSessionHTTPServer();
const sessionId = (server as any).generateSessionId();
// Should be a UUID format (mocked in tests but should be non-empty string with hyphens)
expect(sessionId).toBeTruthy();
expect(typeof sessionId).toBe('string');
expect(sessionId.length).toBeGreaterThan(20); // At minimum should be longer than minimum session ID length
expect(sessionId).toContain('-');
// NOTE: In tests, UUID is mocked so it may not pass strict validation
// In production, generateSessionId uses real uuid.v4() which generates valid UUIDs
});
});
});