mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 05:23:08 +00:00
Fixes 5 failing CI tests by relaxing session ID validation to accept any non-empty string with safe characters (alphanumeric, hyphens, underscores). Changes: - Remove 20-character minimum length requirement - Keep maximum 100-character length for DoS protection - Maintain character whitelist for injection protection - Update tests to reflect relaxed validation policy - Fix mock setup for N8NDocumentationMCPServer in tests Security protections maintained: - Character whitelist prevents SQL/NoSQL injection and path traversal - Maximum length limit prevents DoS attacks - Empty string validation ensures non-empty session IDs Tests fixed: ✅ DELETE /mcp endpoint now returns 404 (not 400) for non-existent sessions ✅ Session ID validation accepts short IDs like '12345', 'short-id' ✅ Idempotent session creation tests pass with proper mock setup Related to PR #312 (Complete Session Persistence Implementation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
552 lines
17 KiB
TypeScript
552 lines
17 KiB
TypeScript
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
|
|
});
|
|
});
|
|
});
|