mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 06:22:04 +00:00
Compare commits
1 Commits
feature/se
...
v2.24.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05424f66af |
57
CHANGELOG.md
57
CHANGELOG.md
@@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.24.1] - 2025-01-24
|
||||
|
||||
### ✨ Features
|
||||
|
||||
**Session Persistence API**
|
||||
|
||||
Added export/restore functionality for MCP sessions to enable zero-downtime deployments in container environments (Kubernetes, Docker Swarm, etc.).
|
||||
|
||||
#### What's New
|
||||
|
||||
**1. Export Session State**
|
||||
- `exportSessionState()` method in `SingleSessionHTTPServer` and `N8NMCPEngine`
|
||||
- Exports all active sessions with metadata and instance context
|
||||
- Automatically filters expired sessions
|
||||
- Returns serializable `SessionState[]` array
|
||||
|
||||
**2. Restore Session State**
|
||||
- `restoreSessionState(sessions)` method for session recovery
|
||||
- Validates session structure using existing `validateInstanceContext()`
|
||||
- Handles null/invalid sessions gracefully with warnings
|
||||
- Enforces MAX_SESSIONS limit (100 concurrent sessions)
|
||||
- Skips expired sessions during restore
|
||||
|
||||
**3. SessionState Type**
|
||||
- New type definition in `src/types/session-state.ts`
|
||||
- Fully documented with JSDoc comments
|
||||
- Includes metadata (timestamps) and context (credentials)
|
||||
- Exported from main package index
|
||||
|
||||
**4. Dormant Session Behavior**
|
||||
- Restored sessions are "dormant" until first request
|
||||
- Transport and server objects recreated on-demand
|
||||
- Memory-efficient session recovery
|
||||
|
||||
#### Security Considerations
|
||||
|
||||
⚠️ **IMPORTANT:** Exported session data contains plaintext n8n API keys. Downstream applications MUST encrypt session data before persisting to disk using AES-256-GCM or equivalent.
|
||||
|
||||
#### Use Cases
|
||||
- Zero-downtime deployments in container orchestration
|
||||
- Session recovery after crashes or restarts
|
||||
- Multi-tenant platform session management
|
||||
- Rolling updates without user disruption
|
||||
|
||||
#### Testing
|
||||
- 22 comprehensive unit tests (100% passing)
|
||||
- Tests cover export, restore, edge cases, and round-trip cycles
|
||||
- Validation of expired session filtering and error handling
|
||||
|
||||
#### Implementation Details
|
||||
- Only exports sessions with valid `n8nApiUrl` and `n8nApiKey` in context
|
||||
- Respects `sessionTimeout` setting (default 30 minutes)
|
||||
- Session metadata and context persisted; transport/server recreated on-demand
|
||||
- Comprehensive error handling with detailed logging
|
||||
|
||||
**Conceived by Romuald Członkowski - [AiAdvisors](https://www.aiadvisors.pl/en)**
|
||||
|
||||
## [2.24.0] - 2025-01-24
|
||||
|
||||
### ✨ Features
|
||||
|
||||
35
CLAUDE.md
35
CLAUDE.md
@@ -32,7 +32,9 @@ src/
|
||||
│ ├── expression-validator.ts # n8n expression syntax validation (NEW in v2.5.0)
|
||||
│ └── workflow-validator.ts # Complete workflow validation (NEW in v2.5.0)
|
||||
├── types/
|
||||
│ └── type-structures.ts # Type structure definitions (NEW in v2.22.21)
|
||||
│ ├── type-structures.ts # Type structure definitions (NEW in v2.22.21)
|
||||
│ ├── instance-context.ts # Multi-tenant instance configuration
|
||||
│ └── session-state.ts # Session persistence types (NEW in v2.24.1)
|
||||
├── constants/
|
||||
│ └── type-structures.ts # 22 complete type structures (NEW in v2.22.21)
|
||||
├── templates/
|
||||
@@ -64,7 +66,9 @@ src/
|
||||
│ ├── console-manager.ts # Console output isolation (NEW in v2.3.1)
|
||||
│ └── logger.ts # Logging utility with HTTP awareness
|
||||
├── http-server-single-session.ts # Single-session HTTP server (NEW in v2.3.1)
|
||||
│ # Session persistence API (NEW in v2.24.1)
|
||||
├── mcp-engine.ts # Clean API for service integration (NEW in v2.3.1)
|
||||
│ # Session persistence wrappers (NEW in v2.24.1)
|
||||
└── index.ts # Library exports
|
||||
```
|
||||
|
||||
@@ -191,6 +195,35 @@ The MCP server exposes tools in several categories:
|
||||
### Development Best Practices
|
||||
- Run typecheck and lint after every code change
|
||||
|
||||
### Session Persistence Feature (v2.24.1)
|
||||
|
||||
**Location:**
|
||||
- Types: `src/types/session-state.ts`
|
||||
- Implementation: `src/http-server-single-session.ts` (lines 698-702, 1444-1584)
|
||||
- Wrapper: `src/mcp-engine.ts` (lines 123-169)
|
||||
- Tests: `tests/unit/http-server/session-persistence.test.ts`, `tests/unit/mcp-engine/session-persistence.test.ts`
|
||||
|
||||
**Key Features:**
|
||||
- **Export/Restore API**: `exportSessionState()` and `restoreSessionState()` methods
|
||||
- **Multi-tenant support**: Enables zero-downtime deployments for SaaS platforms
|
||||
- **Security-first**: API keys exported as plaintext - downstream MUST encrypt
|
||||
- **Dormant sessions**: Restored sessions recreate transports on first request
|
||||
- **Automatic expiration**: Respects `sessionTimeout` setting (default 30 min)
|
||||
- **MAX_SESSIONS limit**: Caps at 100 concurrent sessions
|
||||
|
||||
**Important Implementation Notes:**
|
||||
- Only exports sessions with valid n8nApiUrl and n8nApiKey in context
|
||||
- Skips expired sessions during both export and restore
|
||||
- Uses `validateInstanceContext()` for data integrity checks
|
||||
- Handles null/invalid session gracefully with warnings
|
||||
- Session metadata (timestamps) and context (credentials) are persisted
|
||||
- Transport and server objects are NOT persisted (recreated on-demand)
|
||||
|
||||
**Testing:**
|
||||
- 22 unit tests covering export, restore, edge cases, and round-trip cycles
|
||||
- Tests use current timestamps to avoid expiration issues
|
||||
- Integration with multi-tenant backends documented in README.md
|
||||
|
||||
# important-instruction-reminders
|
||||
Do what has been asked; nothing more, nothing less.
|
||||
NEVER create files unless they're absolutely necessary for achieving your goal.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.24.0",
|
||||
"version": "2.24.1",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
STANDARD_PROTOCOL_VERSION
|
||||
} from './utils/protocol-version';
|
||||
import { InstanceContext, validateInstanceContext } from './types/instance-context';
|
||||
import { SessionState } from './types/session-state';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -71,6 +72,30 @@ function extractMultiTenantHeaders(req: express.Request): MultiTenantHeaders {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Security logging helper for audit trails
|
||||
* Provides structured logging for security-relevant events
|
||||
*/
|
||||
function logSecurityEvent(
|
||||
event: 'session_export' | 'session_restore' | 'session_restore_failed' | 'max_sessions_reached',
|
||||
details: {
|
||||
sessionId?: string;
|
||||
reason?: string;
|
||||
count?: number;
|
||||
instanceId?: string;
|
||||
}
|
||||
): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
event,
|
||||
...details
|
||||
};
|
||||
|
||||
// Log to standard logger with [SECURITY] prefix for easy filtering
|
||||
logger.info(`[SECURITY] ${event}`, logEntry);
|
||||
}
|
||||
|
||||
export class SingleSessionHTTPServer {
|
||||
// Map to store transports by session ID (following SDK pattern)
|
||||
private transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
||||
@@ -687,7 +712,20 @@ export class SingleSessionHTTPServer {
|
||||
if (!this.session) return true;
|
||||
return Date.now() - this.session.lastAccess.getTime() > this.sessionTimeout;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a specific session is expired based on sessionId
|
||||
* Used for multi-session expiration checks during export/restore
|
||||
*
|
||||
* @param sessionId - The session ID to check
|
||||
* @returns true if session is expired or doesn't exist
|
||||
*/
|
||||
private isSessionExpired(sessionId: string): boolean {
|
||||
const metadata = this.sessionMetadata[sessionId];
|
||||
if (!metadata) return true;
|
||||
return Date.now() - metadata.lastAccess.getTime() > this.sessionTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP server
|
||||
*/
|
||||
@@ -1406,6 +1444,197 @@ export class SingleSessionHTTPServer {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all active session state for persistence
|
||||
*
|
||||
* Used by multi-tenant backends to dump sessions before container restart.
|
||||
* This method exports the minimal state needed to restore sessions after
|
||||
* a restart: session metadata (timing) and instance context (credentials).
|
||||
*
|
||||
* Transport and server objects are NOT persisted - they will be recreated
|
||||
* on the first request after restore.
|
||||
*
|
||||
* SECURITY WARNING: The exported data contains plaintext n8n API keys.
|
||||
* The downstream application MUST encrypt this data before persisting to disk.
|
||||
*
|
||||
* @returns Array of session state objects, excluding expired sessions
|
||||
*
|
||||
* @example
|
||||
* // Before shutdown
|
||||
* const sessions = server.exportSessionState();
|
||||
* await saveToEncryptedStorage(sessions);
|
||||
*/
|
||||
public exportSessionState(): SessionState[] {
|
||||
const sessions: SessionState[] = [];
|
||||
const seenSessionIds = new Set<string>();
|
||||
|
||||
// Iterate over all sessions with metadata (source of truth for active sessions)
|
||||
for (const sessionId of Object.keys(this.sessionMetadata)) {
|
||||
// Check for duplicates (defensive programming)
|
||||
if (seenSessionIds.has(sessionId)) {
|
||||
logger.warn(`Duplicate sessionId detected during export: ${sessionId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip expired sessions - they're not worth persisting
|
||||
if (this.isSessionExpired(sessionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = this.sessionMetadata[sessionId];
|
||||
const context = this.sessionContexts[sessionId];
|
||||
|
||||
// Skip sessions without context - these can't be restored meaningfully
|
||||
// (Context is required to reconnect to the correct n8n instance)
|
||||
if (!context || !context.n8nApiUrl || !context.n8nApiKey) {
|
||||
logger.debug(`Skipping session ${sessionId} - missing required context`);
|
||||
continue;
|
||||
}
|
||||
|
||||
seenSessionIds.add(sessionId);
|
||||
sessions.push({
|
||||
sessionId,
|
||||
metadata: {
|
||||
createdAt: metadata.createdAt.toISOString(),
|
||||
lastAccess: metadata.lastAccess.toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: context.n8nApiUrl,
|
||||
n8nApiKey: context.n8nApiKey,
|
||||
instanceId: context.instanceId || sessionId, // Use sessionId as fallback
|
||||
sessionId: context.sessionId,
|
||||
metadata: context.metadata
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Exported ${sessions.length} session(s) for persistence`);
|
||||
logSecurityEvent('session_export', { count: sessions.length });
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore session state from previously exported data
|
||||
*
|
||||
* Used by multi-tenant backends to restore sessions after container restart.
|
||||
* This method restores only the session metadata and instance context.
|
||||
* Transport and server objects will be recreated on the first request.
|
||||
*
|
||||
* Restored sessions are "dormant" until a client makes a request, at which
|
||||
* point the transport and server will be initialized normally.
|
||||
*
|
||||
* @param sessions - Array of session state objects from exportSessionState()
|
||||
* @returns Number of sessions successfully restored
|
||||
*
|
||||
* @example
|
||||
* // After startup
|
||||
* const sessions = await loadFromEncryptedStorage();
|
||||
* const count = server.restoreSessionState(sessions);
|
||||
* console.log(`Restored ${count} sessions`);
|
||||
*/
|
||||
public restoreSessionState(sessions: SessionState[]): number {
|
||||
let restoredCount = 0;
|
||||
|
||||
for (const sessionState of sessions) {
|
||||
try {
|
||||
// Skip null or invalid session objects
|
||||
if (!sessionState || typeof sessionState !== 'object' || !sessionState.sessionId) {
|
||||
logger.warn('Skipping invalid session state object');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've hit the MAX_SESSIONS limit (check real-time count)
|
||||
if (Object.keys(this.sessionMetadata).length >= MAX_SESSIONS) {
|
||||
logger.warn(
|
||||
`Reached MAX_SESSIONS limit (${MAX_SESSIONS}), skipping remaining sessions`
|
||||
);
|
||||
logSecurityEvent('max_sessions_reached', { count: MAX_SESSIONS });
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip if session already exists (duplicate sessionId)
|
||||
if (this.sessionMetadata[sessionState.sessionId]) {
|
||||
logger.debug(`Skipping session ${sessionState.sessionId} - already exists`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse and validate dates first
|
||||
const createdAt = new Date(sessionState.metadata.createdAt);
|
||||
const lastAccess = new Date(sessionState.metadata.lastAccess);
|
||||
|
||||
if (isNaN(createdAt.getTime()) || isNaN(lastAccess.getTime())) {
|
||||
logger.warn(
|
||||
`Skipping session ${sessionState.sessionId} - invalid date format`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate session isn't expired
|
||||
const age = Date.now() - lastAccess.getTime();
|
||||
if (age > this.sessionTimeout) {
|
||||
logger.debug(
|
||||
`Skipping session ${sessionState.sessionId} - expired (age: ${Math.round(age / 1000)}s)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate context exists (TypeScript null narrowing)
|
||||
if (!sessionState.context) {
|
||||
logger.warn(`Skipping session ${sessionState.sessionId} - missing context`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate context structure using existing validation
|
||||
const validation = validateInstanceContext(sessionState.context);
|
||||
if (!validation.valid) {
|
||||
const reason = validation.errors?.join(', ') || 'invalid context';
|
||||
logger.warn(
|
||||
`Skipping session ${sessionState.sessionId} - invalid context: ${reason}`
|
||||
);
|
||||
logSecurityEvent('session_restore_failed', {
|
||||
sessionId: sessionState.sessionId,
|
||||
reason
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Restore session metadata
|
||||
this.sessionMetadata[sessionState.sessionId] = {
|
||||
createdAt,
|
||||
lastAccess
|
||||
};
|
||||
|
||||
// Restore session context
|
||||
this.sessionContexts[sessionState.sessionId] = {
|
||||
n8nApiUrl: sessionState.context.n8nApiUrl,
|
||||
n8nApiKey: sessionState.context.n8nApiKey,
|
||||
instanceId: sessionState.context.instanceId,
|
||||
sessionId: sessionState.context.sessionId,
|
||||
metadata: sessionState.context.metadata
|
||||
};
|
||||
|
||||
logger.debug(`Restored session ${sessionState.sessionId}`);
|
||||
logSecurityEvent('session_restore', {
|
||||
sessionId: sessionState.sessionId,
|
||||
instanceId: sessionState.context.instanceId
|
||||
});
|
||||
restoredCount++;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restore session ${sessionState.sessionId}:`, error);
|
||||
logSecurityEvent('session_restore_failed', {
|
||||
sessionId: sessionState.sessionId,
|
||||
reason: error instanceof Error ? error.message : 'unknown error'
|
||||
});
|
||||
// Continue with next session - don't let one failure break the entire restore
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Restored ${restoredCount}/${sessions.length} session(s) from persistence`
|
||||
);
|
||||
return restoredCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Start if called directly
|
||||
|
||||
@@ -18,6 +18,9 @@ export {
|
||||
validateInstanceContext,
|
||||
isInstanceContext
|
||||
} from './types/instance-context';
|
||||
export type {
|
||||
SessionState
|
||||
} from './types/session-state';
|
||||
|
||||
// Re-export MCP SDK types for convenience
|
||||
export type {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Request, Response } from 'express';
|
||||
import { SingleSessionHTTPServer } from './http-server-single-session';
|
||||
import { logger } from './utils/logger';
|
||||
import { InstanceContext } from './types/instance-context';
|
||||
import { SessionState } from './types/session-state';
|
||||
|
||||
export interface EngineHealth {
|
||||
status: 'healthy' | 'unhealthy';
|
||||
@@ -97,7 +98,7 @@ export class N8NMCPEngine {
|
||||
total: Math.round(memoryUsage.heapTotal / 1024 / 1024),
|
||||
unit: 'MB'
|
||||
},
|
||||
version: '2.3.2'
|
||||
version: '2.24.1'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Health check failed:', error);
|
||||
@@ -106,7 +107,7 @@ export class N8NMCPEngine {
|
||||
uptime: 0,
|
||||
sessionActive: false,
|
||||
memoryUsage: { used: 0, total: 0, unit: 'MB' },
|
||||
version: '2.3.2'
|
||||
version: '2.24.1'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -118,10 +119,58 @@ export class N8NMCPEngine {
|
||||
getSessionInfo(): { active: boolean; sessionId?: string; age?: number } {
|
||||
return this.server.getSessionInfo();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Export all active session state for persistence
|
||||
*
|
||||
* Used by multi-tenant backends to dump sessions before container restart.
|
||||
* Returns an array of session state objects containing metadata and credentials.
|
||||
*
|
||||
* SECURITY WARNING: Exported data contains plaintext n8n API keys.
|
||||
* Encrypt before persisting to disk.
|
||||
*
|
||||
* @returns Array of session state objects
|
||||
*
|
||||
* @example
|
||||
* // Before shutdown
|
||||
* const sessions = engine.exportSessionState();
|
||||
* await saveToEncryptedStorage(sessions);
|
||||
*/
|
||||
exportSessionState(): SessionState[] {
|
||||
if (!this.server) {
|
||||
logger.warn('Cannot export sessions: server not initialized');
|
||||
return [];
|
||||
}
|
||||
return this.server.exportSessionState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore session state from previously exported data
|
||||
*
|
||||
* Used by multi-tenant backends to restore sessions after container restart.
|
||||
* Restores session metadata and instance context. Transports/servers are
|
||||
* recreated on first request.
|
||||
*
|
||||
* @param sessions - Array of session state objects from exportSessionState()
|
||||
* @returns Number of sessions successfully restored
|
||||
*
|
||||
* @example
|
||||
* // After startup
|
||||
* const sessions = await loadFromEncryptedStorage();
|
||||
* const count = engine.restoreSessionState(sessions);
|
||||
* console.log(`Restored ${count} sessions`);
|
||||
*/
|
||||
restoreSessionState(sessions: SessionState[]): number {
|
||||
if (!this.server) {
|
||||
logger.warn('Cannot restore sessions: server not initialized');
|
||||
return 0;
|
||||
}
|
||||
return this.server.restoreSessionState(sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown for service lifecycle
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* process.on('SIGTERM', async () => {
|
||||
* await engine.shutdown();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Export n8n node type definitions and utilities
|
||||
export * from './node-types';
|
||||
export * from './type-structures';
|
||||
export * from './instance-context';
|
||||
export * from './session-state';
|
||||
|
||||
export interface MCPServerConfig {
|
||||
port: number;
|
||||
|
||||
92
src/types/session-state.ts
Normal file
92
src/types/session-state.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Session persistence types for multi-tenant deployments
|
||||
*
|
||||
* These types support exporting and restoring MCP session state across
|
||||
* container restarts, enabling seamless session persistence in production.
|
||||
*/
|
||||
|
||||
import { InstanceContext } from './instance-context.js';
|
||||
|
||||
/**
|
||||
* Serializable session state for persistence across restarts
|
||||
*
|
||||
* This interface represents the minimal state needed to restore an MCP session
|
||||
* after a container restart. Only the session metadata and instance context are
|
||||
* persisted - transport and server objects are recreated on the first request.
|
||||
*
|
||||
* @example
|
||||
* // Export sessions before shutdown
|
||||
* const sessions = server.exportSessionState();
|
||||
* await saveToEncryptedStorage(sessions);
|
||||
*
|
||||
* @example
|
||||
* // Restore sessions on startup
|
||||
* const sessions = await loadFromEncryptedStorage();
|
||||
* const count = server.restoreSessionState(sessions);
|
||||
* console.log(`Restored ${count} sessions`);
|
||||
*/
|
||||
export interface SessionState {
|
||||
/**
|
||||
* Unique session identifier
|
||||
* Format: UUID v4 or custom format from MCP proxy
|
||||
*/
|
||||
sessionId: string;
|
||||
|
||||
/**
|
||||
* Session timing metadata for expiration tracking
|
||||
*/
|
||||
metadata: {
|
||||
/**
|
||||
* When the session was created (ISO 8601 timestamp)
|
||||
* Used to track total session age
|
||||
*/
|
||||
createdAt: string;
|
||||
|
||||
/**
|
||||
* When the session was last accessed (ISO 8601 timestamp)
|
||||
* Used to determine if session has expired based on timeout
|
||||
*/
|
||||
lastAccess: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* n8n instance context (credentials and configuration)
|
||||
*
|
||||
* Contains the n8n API credentials and instance-specific settings.
|
||||
* This is the critical data needed to reconnect to the correct n8n instance.
|
||||
*
|
||||
* Note: API keys are stored in plaintext. The downstream application
|
||||
* MUST encrypt this data before persisting to disk.
|
||||
*/
|
||||
context: {
|
||||
/**
|
||||
* n8n instance API URL
|
||||
* Example: "https://n8n.example.com"
|
||||
*/
|
||||
n8nApiUrl: string;
|
||||
|
||||
/**
|
||||
* n8n instance API key (plaintext - encrypt before storage!)
|
||||
* Example: "n8n_api_1234567890abcdef"
|
||||
*/
|
||||
n8nApiKey: string;
|
||||
|
||||
/**
|
||||
* Instance identifier (optional)
|
||||
* Custom identifier for tracking which n8n instance this session belongs to
|
||||
*/
|
||||
instanceId?: string;
|
||||
|
||||
/**
|
||||
* Session-specific ID (optional)
|
||||
* May differ from top-level sessionId in some proxy configurations
|
||||
*/
|
||||
sessionId?: string;
|
||||
|
||||
/**
|
||||
* Additional metadata (optional)
|
||||
* Extensible field for custom application data
|
||||
*/
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
546
tests/unit/http-server/session-persistence.test.ts
Normal file
546
tests/unit/http-server/session-persistence.test.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Unit tests for session persistence API
|
||||
* Tests export and restore functionality for multi-tenant session management
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { SingleSessionHTTPServer } from '../../../src/http-server-single-session';
|
||||
import { SessionState } from '../../../src/types/session-state';
|
||||
|
||||
describe('SingleSessionHTTPServer - Session Persistence', () => {
|
||||
let server: SingleSessionHTTPServer;
|
||||
|
||||
beforeEach(() => {
|
||||
server = new SingleSessionHTTPServer();
|
||||
});
|
||||
|
||||
describe('exportSessionState()', () => {
|
||||
it('should return empty array when no sessions exist', () => {
|
||||
const exported = server.exportSessionState();
|
||||
expect(exported).toEqual([]);
|
||||
});
|
||||
|
||||
it('should export active sessions with all required fields', () => {
|
||||
// Create mock sessions by directly manipulating internal state
|
||||
const sessionId1 = 'test-session-1';
|
||||
const sessionId2 = 'test-session-2';
|
||||
|
||||
// Use current timestamps to avoid expiration
|
||||
const now = new Date();
|
||||
const createdAt1 = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||
const lastAccess1 = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||
const createdAt2 = new Date(now.getTime() - 15 * 60 * 1000); // 15 minutes ago
|
||||
const lastAccess2 = new Date(now.getTime() - 3 * 60 * 1000); // 3 minutes ago
|
||||
|
||||
// Access private properties for testing
|
||||
const serverAny = server as any;
|
||||
|
||||
serverAny.sessionMetadata[sessionId1] = {
|
||||
createdAt: createdAt1,
|
||||
lastAccess: lastAccess1
|
||||
};
|
||||
|
||||
serverAny.sessionContexts[sessionId1] = {
|
||||
n8nApiUrl: 'https://n8n1.example.com',
|
||||
n8nApiKey: 'key1',
|
||||
instanceId: 'instance1',
|
||||
sessionId: sessionId1,
|
||||
metadata: { userId: 'user1' }
|
||||
};
|
||||
|
||||
serverAny.sessionMetadata[sessionId2] = {
|
||||
createdAt: createdAt2,
|
||||
lastAccess: lastAccess2
|
||||
};
|
||||
|
||||
serverAny.sessionContexts[sessionId2] = {
|
||||
n8nApiUrl: 'https://n8n2.example.com',
|
||||
n8nApiKey: 'key2',
|
||||
instanceId: 'instance2'
|
||||
};
|
||||
|
||||
const exported = server.exportSessionState();
|
||||
|
||||
expect(exported).toHaveLength(2);
|
||||
|
||||
// Verify first session
|
||||
expect(exported[0]).toMatchObject({
|
||||
sessionId: sessionId1,
|
||||
metadata: {
|
||||
createdAt: createdAt1.toISOString(),
|
||||
lastAccess: lastAccess1.toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://n8n1.example.com',
|
||||
n8nApiKey: 'key1',
|
||||
instanceId: 'instance1',
|
||||
sessionId: sessionId1,
|
||||
metadata: { userId: 'user1' }
|
||||
}
|
||||
});
|
||||
|
||||
// Verify second session
|
||||
expect(exported[1]).toMatchObject({
|
||||
sessionId: sessionId2,
|
||||
metadata: {
|
||||
createdAt: createdAt2.toISOString(),
|
||||
lastAccess: lastAccess2.toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://n8n2.example.com',
|
||||
n8nApiKey: 'key2',
|
||||
instanceId: 'instance2'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip expired sessions during export', () => {
|
||||
const serverAny = server as any;
|
||||
const now = Date.now();
|
||||
const sessionTimeout = 30 * 60 * 1000; // 30 minutes (default)
|
||||
|
||||
// Create an active session (accessed recently)
|
||||
serverAny.sessionMetadata['active-session'] = {
|
||||
createdAt: new Date(now - 10 * 60 * 1000), // 10 minutes ago
|
||||
lastAccess: new Date(now - 5 * 60 * 1000) // 5 minutes ago
|
||||
};
|
||||
serverAny.sessionContexts['active-session'] = {
|
||||
n8nApiUrl: 'https://active.example.com',
|
||||
n8nApiKey: 'active-key',
|
||||
instanceId: 'active-instance'
|
||||
};
|
||||
|
||||
// Create an expired session (last accessed > 30 minutes ago)
|
||||
serverAny.sessionMetadata['expired-session'] = {
|
||||
createdAt: new Date(now - 60 * 60 * 1000), // 60 minutes ago
|
||||
lastAccess: new Date(now - 45 * 60 * 1000) // 45 minutes ago (expired)
|
||||
};
|
||||
serverAny.sessionContexts['expired-session'] = {
|
||||
n8nApiUrl: 'https://expired.example.com',
|
||||
n8nApiKey: 'expired-key',
|
||||
instanceId: 'expired-instance'
|
||||
};
|
||||
|
||||
const exported = server.exportSessionState();
|
||||
|
||||
expect(exported).toHaveLength(1);
|
||||
expect(exported[0].sessionId).toBe('active-session');
|
||||
});
|
||||
|
||||
it('should skip sessions without required context fields', () => {
|
||||
const serverAny = server as any;
|
||||
|
||||
// Session with complete context
|
||||
serverAny.sessionMetadata['complete-session'] = {
|
||||
createdAt: new Date(),
|
||||
lastAccess: new Date()
|
||||
};
|
||||
serverAny.sessionContexts['complete-session'] = {
|
||||
n8nApiUrl: 'https://complete.example.com',
|
||||
n8nApiKey: 'complete-key',
|
||||
instanceId: 'complete-instance'
|
||||
};
|
||||
|
||||
// Session with missing n8nApiUrl
|
||||
serverAny.sessionMetadata['missing-url'] = {
|
||||
createdAt: new Date(),
|
||||
lastAccess: new Date()
|
||||
};
|
||||
serverAny.sessionContexts['missing-url'] = {
|
||||
n8nApiKey: 'key',
|
||||
instanceId: 'instance'
|
||||
};
|
||||
|
||||
// Session with missing n8nApiKey
|
||||
serverAny.sessionMetadata['missing-key'] = {
|
||||
createdAt: new Date(),
|
||||
lastAccess: new Date()
|
||||
};
|
||||
serverAny.sessionContexts['missing-key'] = {
|
||||
n8nApiUrl: 'https://example.com',
|
||||
instanceId: 'instance'
|
||||
};
|
||||
|
||||
// Session with no context at all
|
||||
serverAny.sessionMetadata['no-context'] = {
|
||||
createdAt: new Date(),
|
||||
lastAccess: new Date()
|
||||
};
|
||||
|
||||
const exported = server.exportSessionState();
|
||||
|
||||
expect(exported).toHaveLength(1);
|
||||
expect(exported[0].sessionId).toBe('complete-session');
|
||||
});
|
||||
|
||||
it('should use sessionId as fallback for instanceId', () => {
|
||||
const serverAny = server as any;
|
||||
const sessionId = 'test-session';
|
||||
|
||||
serverAny.sessionMetadata[sessionId] = {
|
||||
createdAt: new Date(),
|
||||
lastAccess: new Date()
|
||||
};
|
||||
serverAny.sessionContexts[sessionId] = {
|
||||
n8nApiUrl: 'https://example.com',
|
||||
n8nApiKey: 'key'
|
||||
// No instanceId provided
|
||||
};
|
||||
|
||||
const exported = server.exportSessionState();
|
||||
|
||||
expect(exported).toHaveLength(1);
|
||||
expect(exported[0].context.instanceId).toBe(sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreSessionState()', () => {
|
||||
it('should restore valid sessions correctly', () => {
|
||||
const sessions: SessionState[] = [
|
||||
{
|
||||
sessionId: 'restored-session-1',
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://restored1.example.com',
|
||||
n8nApiKey: 'restored-key-1',
|
||||
instanceId: 'restored-instance-1'
|
||||
}
|
||||
},
|
||||
{
|
||||
sessionId: 'restored-session-2',
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://restored2.example.com',
|
||||
n8nApiKey: 'restored-key-2',
|
||||
instanceId: 'restored-instance-2',
|
||||
sessionId: 'custom-session-id',
|
||||
metadata: { custom: 'data' }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const count = server.restoreSessionState(sessions);
|
||||
|
||||
expect(count).toBe(2);
|
||||
|
||||
// Verify sessions were restored by checking internal state
|
||||
const serverAny = server as any;
|
||||
|
||||
expect(serverAny.sessionMetadata['restored-session-1']).toBeDefined();
|
||||
expect(serverAny.sessionContexts['restored-session-1']).toMatchObject({
|
||||
n8nApiUrl: 'https://restored1.example.com',
|
||||
n8nApiKey: 'restored-key-1',
|
||||
instanceId: 'restored-instance-1'
|
||||
});
|
||||
|
||||
expect(serverAny.sessionMetadata['restored-session-2']).toBeDefined();
|
||||
expect(serverAny.sessionContexts['restored-session-2']).toMatchObject({
|
||||
n8nApiUrl: 'https://restored2.example.com',
|
||||
n8nApiKey: 'restored-key-2',
|
||||
instanceId: 'restored-instance-2',
|
||||
sessionId: 'custom-session-id',
|
||||
metadata: { custom: 'data' }
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip expired sessions during restore', () => {
|
||||
const now = Date.now();
|
||||
const sessionTimeout = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
const sessions: SessionState[] = [
|
||||
{
|
||||
sessionId: 'active-session',
|
||||
metadata: {
|
||||
createdAt: new Date(now - 10 * 60 * 1000).toISOString(),
|
||||
lastAccess: new Date(now - 5 * 60 * 1000).toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://active.example.com',
|
||||
n8nApiKey: 'active-key',
|
||||
instanceId: 'active-instance'
|
||||
}
|
||||
},
|
||||
{
|
||||
sessionId: 'expired-session',
|
||||
metadata: {
|
||||
createdAt: new Date(now - 60 * 60 * 1000).toISOString(),
|
||||
lastAccess: new Date(now - 45 * 60 * 1000).toISOString() // Expired
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://expired.example.com',
|
||||
n8nApiKey: 'expired-key',
|
||||
instanceId: 'expired-instance'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const count = server.restoreSessionState(sessions);
|
||||
|
||||
expect(count).toBe(1);
|
||||
|
||||
const serverAny = server as any;
|
||||
expect(serverAny.sessionMetadata['active-session']).toBeDefined();
|
||||
expect(serverAny.sessionMetadata['expired-session']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip sessions with missing required context fields', () => {
|
||||
const sessions: SessionState[] = [
|
||||
{
|
||||
sessionId: 'valid-session',
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://valid.example.com',
|
||||
n8nApiKey: 'valid-key',
|
||||
instanceId: 'valid-instance'
|
||||
}
|
||||
},
|
||||
{
|
||||
sessionId: 'missing-url',
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: '', // Empty URL
|
||||
n8nApiKey: 'key',
|
||||
instanceId: 'instance'
|
||||
}
|
||||
},
|
||||
{
|
||||
sessionId: 'missing-key',
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://example.com',
|
||||
n8nApiKey: '', // Empty key
|
||||
instanceId: 'instance'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const count = server.restoreSessionState(sessions);
|
||||
|
||||
expect(count).toBe(1);
|
||||
|
||||
const serverAny = server as any;
|
||||
expect(serverAny.sessionMetadata['valid-session']).toBeDefined();
|
||||
expect(serverAny.sessionMetadata['missing-url']).toBeUndefined();
|
||||
expect(serverAny.sessionMetadata['missing-key']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip duplicate sessionIds', () => {
|
||||
const serverAny = server as any;
|
||||
|
||||
// Create an existing session
|
||||
serverAny.sessionMetadata['existing-session'] = {
|
||||
createdAt: new Date(),
|
||||
lastAccess: new Date()
|
||||
};
|
||||
|
||||
const sessions: SessionState[] = [
|
||||
{
|
||||
sessionId: 'new-session',
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://new.example.com',
|
||||
n8nApiKey: 'new-key',
|
||||
instanceId: 'new-instance'
|
||||
}
|
||||
},
|
||||
{
|
||||
sessionId: 'existing-session', // Duplicate
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://duplicate.example.com',
|
||||
n8nApiKey: 'duplicate-key',
|
||||
instanceId: 'duplicate-instance'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const count = server.restoreSessionState(sessions);
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(serverAny.sessionMetadata['new-session']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle restore failures gracefully', () => {
|
||||
const sessions: any[] = [
|
||||
{
|
||||
sessionId: 'valid-session',
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://valid.example.com',
|
||||
n8nApiKey: 'valid-key',
|
||||
instanceId: 'valid-instance'
|
||||
}
|
||||
},
|
||||
{
|
||||
sessionId: 'bad-session',
|
||||
metadata: {}, // Missing required fields
|
||||
context: null // Invalid context
|
||||
},
|
||||
null, // Invalid session
|
||||
{
|
||||
// Missing sessionId
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://example.com',
|
||||
n8nApiKey: 'key',
|
||||
instanceId: 'instance'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Should not throw and should restore only the valid session
|
||||
expect(() => {
|
||||
const count = server.restoreSessionState(sessions);
|
||||
expect(count).toBe(1); // Only valid-session should be restored
|
||||
}).not.toThrow();
|
||||
|
||||
// Verify the valid session was restored
|
||||
const serverAny = server as any;
|
||||
expect(serverAny.sessionMetadata['valid-session']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should respect MAX_SESSIONS limit during restore', () => {
|
||||
// Create 99 existing sessions (MAX_SESSIONS is 100)
|
||||
const serverAny = server as any;
|
||||
const now = new Date();
|
||||
for (let i = 0; i < 99; i++) {
|
||||
serverAny.sessionMetadata[`existing-${i}`] = {
|
||||
createdAt: now,
|
||||
lastAccess: now
|
||||
};
|
||||
}
|
||||
|
||||
// Try to restore 3 sessions (should only restore 1 due to limit)
|
||||
const sessions: SessionState[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
sessions.push({
|
||||
sessionId: `new-session-${i}`,
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: `https://new${i}.example.com`,
|
||||
n8nApiKey: `new-key-${i}`,
|
||||
instanceId: `new-instance-${i}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const count = server.restoreSessionState(sessions);
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(serverAny.sessionMetadata['new-session-0']).toBeDefined();
|
||||
expect(serverAny.sessionMetadata['new-session-1']).toBeUndefined();
|
||||
expect(serverAny.sessionMetadata['new-session-2']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse ISO 8601 timestamps correctly', () => {
|
||||
// Use current timestamps to avoid expiration
|
||||
const now = new Date();
|
||||
const createdAtDate = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||
const lastAccessDate = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||
const createdAt = createdAtDate.toISOString();
|
||||
const lastAccess = lastAccessDate.toISOString();
|
||||
|
||||
const sessions: SessionState[] = [
|
||||
{
|
||||
sessionId: 'timestamp-session',
|
||||
metadata: { createdAt, lastAccess },
|
||||
context: {
|
||||
n8nApiUrl: 'https://example.com',
|
||||
n8nApiKey: 'key',
|
||||
instanceId: 'instance'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const count = server.restoreSessionState(sessions);
|
||||
expect(count).toBe(1);
|
||||
|
||||
const serverAny = server as any;
|
||||
const metadata = serverAny.sessionMetadata['timestamp-session'];
|
||||
|
||||
expect(metadata.createdAt).toBeInstanceOf(Date);
|
||||
expect(metadata.lastAccess).toBeInstanceOf(Date);
|
||||
expect(metadata.createdAt.toISOString()).toBe(createdAt);
|
||||
expect(metadata.lastAccess.toISOString()).toBe(lastAccess);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Round-trip export and restore', () => {
|
||||
it('should preserve data through export → restore cycle', () => {
|
||||
// Create sessions with current timestamps
|
||||
const serverAny = server as any;
|
||||
const now = new Date();
|
||||
const createdAt = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||
const lastAccess = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||
|
||||
serverAny.sessionMetadata['session-1'] = {
|
||||
createdAt,
|
||||
lastAccess
|
||||
};
|
||||
serverAny.sessionContexts['session-1'] = {
|
||||
n8nApiUrl: 'https://n8n1.example.com',
|
||||
n8nApiKey: 'key1',
|
||||
instanceId: 'instance1',
|
||||
sessionId: 'custom-id-1',
|
||||
metadata: { userId: 'user1', role: 'admin' }
|
||||
};
|
||||
|
||||
// Export sessions
|
||||
const exported = server.exportSessionState();
|
||||
expect(exported).toHaveLength(1);
|
||||
|
||||
// Clear sessions
|
||||
delete serverAny.sessionMetadata['session-1'];
|
||||
delete serverAny.sessionContexts['session-1'];
|
||||
|
||||
// Restore sessions
|
||||
const count = server.restoreSessionState(exported);
|
||||
expect(count).toBe(1);
|
||||
|
||||
// Verify data integrity
|
||||
const metadata = serverAny.sessionMetadata['session-1'];
|
||||
const context = serverAny.sessionContexts['session-1'];
|
||||
|
||||
expect(metadata.createdAt.toISOString()).toBe(createdAt.toISOString());
|
||||
expect(metadata.lastAccess.toISOString()).toBe(lastAccess.toISOString());
|
||||
|
||||
expect(context).toMatchObject({
|
||||
n8nApiUrl: 'https://n8n1.example.com',
|
||||
n8nApiKey: 'key1',
|
||||
instanceId: 'instance1',
|
||||
sessionId: 'custom-id-1',
|
||||
metadata: { userId: 'user1', role: 'admin' }
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
255
tests/unit/mcp-engine/session-persistence.test.ts
Normal file
255
tests/unit/mcp-engine/session-persistence.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Unit tests for N8NMCPEngine session persistence wrapper methods
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { N8NMCPEngine } from '../../../src/mcp-engine';
|
||||
import { SessionState } from '../../../src/types/session-state';
|
||||
|
||||
describe('N8NMCPEngine - Session Persistence', () => {
|
||||
let engine: N8NMCPEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
engine = new N8NMCPEngine({
|
||||
sessionTimeout: 30 * 60 * 1000,
|
||||
logLevel: 'error' // Quiet during tests
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportSessionState()', () => {
|
||||
it('should return empty array when no sessions exist', () => {
|
||||
const exported = engine.exportSessionState();
|
||||
expect(exported).toEqual([]);
|
||||
});
|
||||
|
||||
it('should delegate to underlying server', () => {
|
||||
// Access private server to create test sessions
|
||||
const engineAny = engine as any;
|
||||
const server = engineAny.server;
|
||||
const serverAny = server as any;
|
||||
|
||||
// Create a mock session
|
||||
serverAny.sessionMetadata['test-session'] = {
|
||||
createdAt: new Date(),
|
||||
lastAccess: new Date()
|
||||
};
|
||||
serverAny.sessionContexts['test-session'] = {
|
||||
n8nApiUrl: 'https://test.example.com',
|
||||
n8nApiKey: 'test-key',
|
||||
instanceId: 'test-instance'
|
||||
};
|
||||
|
||||
const exported = engine.exportSessionState();
|
||||
|
||||
expect(exported).toHaveLength(1);
|
||||
expect(exported[0].sessionId).toBe('test-session');
|
||||
expect(exported[0].context.n8nApiUrl).toBe('https://test.example.com');
|
||||
});
|
||||
|
||||
it('should handle server not initialized', () => {
|
||||
// Create engine without server
|
||||
const engineAny = {} as N8NMCPEngine;
|
||||
const exportMethod = N8NMCPEngine.prototype.exportSessionState.bind(engineAny);
|
||||
|
||||
// Should not throw, should return empty array
|
||||
expect(() => exportMethod()).not.toThrow();
|
||||
const result = exportMethod();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreSessionState()', () => {
|
||||
it('should restore sessions via underlying server', () => {
|
||||
const sessions: SessionState[] = [
|
||||
{
|
||||
sessionId: 'restored-session',
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://restored.example.com',
|
||||
n8nApiKey: 'restored-key',
|
||||
instanceId: 'restored-instance'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const count = engine.restoreSessionState(sessions);
|
||||
|
||||
expect(count).toBe(1);
|
||||
|
||||
// Verify session was restored
|
||||
const engineAny = engine as any;
|
||||
const server = engineAny.server;
|
||||
const serverAny = server as any;
|
||||
|
||||
expect(serverAny.sessionMetadata['restored-session']).toBeDefined();
|
||||
expect(serverAny.sessionContexts['restored-session']).toMatchObject({
|
||||
n8nApiUrl: 'https://restored.example.com',
|
||||
n8nApiKey: 'restored-key',
|
||||
instanceId: 'restored-instance'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 0 when restoring empty array', () => {
|
||||
const count = engine.restoreSessionState([]);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle server not initialized', () => {
|
||||
const engineAny = {} as N8NMCPEngine;
|
||||
const restoreMethod = N8NMCPEngine.prototype.restoreSessionState.bind(engineAny);
|
||||
|
||||
const sessions: SessionState[] = [
|
||||
{
|
||||
sessionId: 'test',
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://test.example.com',
|
||||
n8nApiKey: 'test-key',
|
||||
instanceId: 'test-instance'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Should not throw, should return 0
|
||||
expect(() => restoreMethod(sessions)).not.toThrow();
|
||||
const result = restoreMethod(sessions);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should return count of successfully restored sessions', () => {
|
||||
const now = Date.now();
|
||||
const sessions: SessionState[] = [
|
||||
{
|
||||
sessionId: 'valid-1',
|
||||
metadata: {
|
||||
createdAt: new Date(now - 10 * 60 * 1000).toISOString(),
|
||||
lastAccess: new Date(now - 5 * 60 * 1000).toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://valid1.example.com',
|
||||
n8nApiKey: 'key1',
|
||||
instanceId: 'instance1'
|
||||
}
|
||||
},
|
||||
{
|
||||
sessionId: 'valid-2',
|
||||
metadata: {
|
||||
createdAt: new Date(now - 10 * 60 * 1000).toISOString(),
|
||||
lastAccess: new Date(now - 5 * 60 * 1000).toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://valid2.example.com',
|
||||
n8nApiKey: 'key2',
|
||||
instanceId: 'instance2'
|
||||
}
|
||||
},
|
||||
{
|
||||
sessionId: 'expired',
|
||||
metadata: {
|
||||
createdAt: new Date(now - 60 * 60 * 1000).toISOString(),
|
||||
lastAccess: new Date(now - 45 * 60 * 1000).toISOString() // Expired
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://expired.example.com',
|
||||
n8nApiKey: 'expired-key',
|
||||
instanceId: 'expired-instance'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const count = engine.restoreSessionState(sessions);
|
||||
|
||||
expect(count).toBe(2); // Only 2 valid sessions
|
||||
});
|
||||
});
|
||||
|
||||
describe('Round-trip through engine', () => {
|
||||
it('should preserve sessions through export → restore cycle', () => {
|
||||
// Create mock sessions with current timestamps
|
||||
const engineAny = engine as any;
|
||||
const server = engineAny.server;
|
||||
const serverAny = server as any;
|
||||
|
||||
const now = new Date();
|
||||
const createdAt = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||
const lastAccess = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||
|
||||
serverAny.sessionMetadata['engine-session'] = {
|
||||
createdAt,
|
||||
lastAccess
|
||||
};
|
||||
serverAny.sessionContexts['engine-session'] = {
|
||||
n8nApiUrl: 'https://engine-test.example.com',
|
||||
n8nApiKey: 'engine-key',
|
||||
instanceId: 'engine-instance',
|
||||
metadata: { env: 'production' }
|
||||
};
|
||||
|
||||
// Export via engine
|
||||
const exported = engine.exportSessionState();
|
||||
expect(exported).toHaveLength(1);
|
||||
|
||||
// Clear sessions
|
||||
delete serverAny.sessionMetadata['engine-session'];
|
||||
delete serverAny.sessionContexts['engine-session'];
|
||||
|
||||
// Restore via engine
|
||||
const count = engine.restoreSessionState(exported);
|
||||
expect(count).toBe(1);
|
||||
|
||||
// Verify data
|
||||
expect(serverAny.sessionMetadata['engine-session']).toBeDefined();
|
||||
expect(serverAny.sessionContexts['engine-session']).toMatchObject({
|
||||
n8nApiUrl: 'https://engine-test.example.com',
|
||||
n8nApiKey: 'engine-key',
|
||||
instanceId: 'engine-instance',
|
||||
metadata: { env: 'production' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with getSessionInfo()', () => {
|
||||
it('should reflect restored sessions in session info', () => {
|
||||
const sessions: SessionState[] = [
|
||||
{
|
||||
sessionId: 'info-session-1',
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://info1.example.com',
|
||||
n8nApiKey: 'info-key-1',
|
||||
instanceId: 'info-instance-1'
|
||||
}
|
||||
},
|
||||
{
|
||||
sessionId: 'info-session-2',
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccess: new Date().toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://info2.example.com',
|
||||
n8nApiKey: 'info-key-2',
|
||||
instanceId: 'info-instance-2'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
engine.restoreSessionState(sessions);
|
||||
|
||||
const info = engine.getSessionInfo();
|
||||
|
||||
// Note: getSessionInfo() reflects metadata, not transports
|
||||
// Restored sessions won't have transports until first request
|
||||
expect(info).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user