409e92d6ae
Build and Push Docker Images / Build Docker Image (push) Has been cancelled
Build and Push Docker Images / Build Railway Docker Image (push) Has been cancelled
Build and Publish n8n Docker Image / test-image (push) Has been cancelled
Dependency Compatibility Check / Fresh Install Dependency Check (push) Has been cancelled
Build and Publish n8n Docker Image / build-and-push (push) Has been cancelled
Build and Publish n8n Docker Image / create-release (push) Has been cancelled
Automated Release / Detect Version Change (push) Has been cancelled
Automated Release / Generate Release Notes (push) Has been cancelled
Automated Release / Create GitHub Release (push) Has been cancelled
Automated Release / Package MCPB Bundle (push) Has been cancelled
Automated Release / Build and Verify (push) Has been cancelled
Automated Release / Publish to NPM (push) Has been cancelled
Automated Release / Build and Push Docker Images (push) Has been cancelled
Automated Release / Update Documentation (push) Has been cancelled
Automated Release / Notify Release Completion (push) Has been cancelled
Secret Scan / secretlint (push) Has been cancelled
Test Suite / test (push) Has been cancelled
Test Suite / cjs-runtime (push) Has been cancelled
Test Suite / publish-results (push) Has been cancelled
726 lines
23 KiB
TypeScript
726 lines
23 KiB
TypeScript
/**
|
|
* Unit tests for session persistence API
|
|
* Tests export and restore functionality for multi-tenant session management
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
// Mock the logger so we can assert on security-event emission. logSecurityEvent
|
|
// in http-server-single-session.ts routes events through logger.info with a
|
|
// `[SECURITY] <event>` prefix, so spying on logger.info lets us verify which
|
|
// security events fire during restore.
|
|
vi.mock('../../../src/utils/logger', () => ({
|
|
Logger: vi.fn().mockImplementation(() => ({
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn()
|
|
})),
|
|
logger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn()
|
|
}
|
|
}));
|
|
|
|
import { SingleSessionHTTPServer } from '../../../src/http-server-single-session';
|
|
import { SessionState } from '../../../src/types/session-state';
|
|
import { logger } from '../../../src/utils/logger';
|
|
|
|
describe('SingleSessionHTTPServer - Session Persistence', () => {
|
|
let server: SingleSessionHTTPServer;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
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() - 2 * 60 * 1000); // 2 minutes ago
|
|
const lastAccess1 = new Date(now.getTime() - 30 * 1000); // 30 seconds ago
|
|
const createdAt2 = new Date(now.getTime() - 3 * 60 * 1000); // 3 minutes ago
|
|
const lastAccess2 = new Date(now.getTime() - 20 * 1000); // 20 seconds 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 - 2 * 60 * 1000), // 2 minutes ago
|
|
lastAccess: new Date(now - 30 * 1000) // 30 seconds 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 - 2 * 60 * 1000).toISOString(),
|
|
lastAccess: new Date(now - 30 * 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 defaults to 100, configurable via N8N_MCP_MAX_SESSIONS env var)
|
|
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() - 2 * 60 * 1000); // 2 minutes ago
|
|
const lastAccessDate = new Date(now.getTime() - 30 * 1000); // 30 seconds 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('restoreSessionState() - partial tenant context hardening (#844)', () => {
|
|
// Helper: find logSecurityEvent emissions for a given event name.
|
|
// logSecurityEvent routes through logger.info(`[SECURITY] <event>`, details),
|
|
// so we inspect logger.info calls for the prefixed message.
|
|
const securityEventsFor = (event: string) =>
|
|
vi.mocked(logger.info).mock.calls.filter(
|
|
(call) => call[0] === `[SECURITY] ${event}`
|
|
);
|
|
|
|
it('should reject a context carrying only n8nApiUrl (key absent)', () => {
|
|
// Field is entirely absent (undefined), not empty string. This passes
|
|
// validateInstanceContext (which only checks fields that are !== undefined)
|
|
// but must still be rejected as a partial tenant identity.
|
|
const sessions: SessionState[] = [
|
|
{
|
|
sessionId: 'url-only-session',
|
|
metadata: {
|
|
createdAt: new Date().toISOString(),
|
|
lastAccess: new Date().toISOString()
|
|
},
|
|
context: {
|
|
n8nApiUrl: 'https://url-only.example.com',
|
|
instanceId: 'partial-instance'
|
|
// n8nApiKey absent
|
|
} as any
|
|
}
|
|
];
|
|
|
|
const count = server.restoreSessionState(sessions);
|
|
|
|
expect(count).toBe(0);
|
|
const serverAny = server as any;
|
|
expect(serverAny.sessionMetadata['url-only-session']).toBeUndefined();
|
|
expect(serverAny.sessionContexts['url-only-session']).toBeUndefined();
|
|
});
|
|
|
|
it('should reject a context carrying only n8nApiKey (url absent)', () => {
|
|
const sessions: SessionState[] = [
|
|
{
|
|
sessionId: 'key-only-session',
|
|
metadata: {
|
|
createdAt: new Date().toISOString(),
|
|
lastAccess: new Date().toISOString()
|
|
},
|
|
context: {
|
|
n8nApiKey: 'key-only-secret',
|
|
instanceId: 'partial-instance'
|
|
// n8nApiUrl absent
|
|
} as any
|
|
}
|
|
];
|
|
|
|
const count = server.restoreSessionState(sessions);
|
|
|
|
expect(count).toBe(0);
|
|
const serverAny = server as any;
|
|
expect(serverAny.sessionMetadata['key-only-session']).toBeUndefined();
|
|
expect(serverAny.sessionContexts['key-only-session']).toBeUndefined();
|
|
});
|
|
|
|
it('should emit a session_restore_failed security event for a partial context', () => {
|
|
const sessions: SessionState[] = [
|
|
{
|
|
sessionId: 'partial-event-session',
|
|
metadata: {
|
|
createdAt: new Date().toISOString(),
|
|
lastAccess: new Date().toISOString()
|
|
},
|
|
context: {
|
|
n8nApiUrl: 'https://partial.example.com'
|
|
// n8nApiKey absent
|
|
} as any
|
|
}
|
|
];
|
|
|
|
server.restoreSessionState(sessions);
|
|
|
|
const events = securityEventsFor('session_restore_failed');
|
|
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const partialEvent = events.find(
|
|
(call) => (call[1] as any)?.sessionId === 'partial-event-session'
|
|
);
|
|
expect(partialEvent).toBeDefined();
|
|
expect((partialEvent![1] as any).reason).toContain(
|
|
'missing required tenant credentials'
|
|
);
|
|
});
|
|
|
|
it('should restore a complete context normally (control)', () => {
|
|
const sessions: SessionState[] = [
|
|
{
|
|
sessionId: 'complete-session',
|
|
metadata: {
|
|
createdAt: new Date().toISOString(),
|
|
lastAccess: new Date().toISOString()
|
|
},
|
|
context: {
|
|
n8nApiUrl: 'https://complete.example.com',
|
|
n8nApiKey: 'complete-key',
|
|
instanceId: 'complete-instance'
|
|
}
|
|
}
|
|
];
|
|
|
|
const count = server.restoreSessionState(sessions);
|
|
|
|
expect(count).toBe(1);
|
|
const serverAny = server as any;
|
|
expect(serverAny.sessionMetadata['complete-session']).toBeDefined();
|
|
expect(serverAny.sessionContexts['complete-session']).toMatchObject({
|
|
n8nApiUrl: 'https://complete.example.com',
|
|
n8nApiKey: 'complete-key',
|
|
instanceId: 'complete-instance'
|
|
});
|
|
|
|
// The partial-context guard must NOT have fired for a complete context.
|
|
const events = securityEventsFor('session_restore_failed');
|
|
const completeEvent = events.find(
|
|
(call) => (call[1] as any)?.sessionId === 'complete-session'
|
|
);
|
|
expect(completeEvent).toBeUndefined();
|
|
});
|
|
|
|
it('should leave a no-context (single-tenant/stdio) session unaffected', () => {
|
|
// A session with no context at all is handled by the earlier null-context
|
|
// check and is skipped (not restored) WITHOUT being misclassified by the
|
|
// partial-credential guard. The guard must never run for it.
|
|
const sessions: any[] = [
|
|
{
|
|
sessionId: 'no-context-session',
|
|
metadata: {
|
|
createdAt: new Date().toISOString(),
|
|
lastAccess: new Date().toISOString()
|
|
}
|
|
// context omitted entirely
|
|
}
|
|
];
|
|
|
|
const count = server.restoreSessionState(sessions);
|
|
|
|
// No-context sessions are not restorable (no instance to reconnect to).
|
|
expect(count).toBe(0);
|
|
const serverAny = server as any;
|
|
expect(serverAny.sessionMetadata['no-context-session']).toBeUndefined();
|
|
|
|
// The earlier `!sessionState.context` branch logs a plain warning and does
|
|
// NOT emit a session_restore_failed security event, so the partial-context
|
|
// guard provably did not run for this no-context session.
|
|
const events = securityEventsFor('session_restore_failed');
|
|
const noContextEvent = events.find(
|
|
(call) => (call[1] as any)?.sessionId === 'no-context-session'
|
|
);
|
|
expect(noContextEvent).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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() - 60 * 1000); // 1 minute ago
|
|
const lastAccess = new Date(now.getTime() - 30 * 1000); // 30 seconds 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' }
|
|
});
|
|
});
|
|
});
|
|
});
|