feat: Session Persistence API for Zero-Downtime Deployments (v2.24.1) (#438)

* feat: Add session persistence API for zero-downtime deployments (v2.24.1)

Implements export/restore functionality for MCP sessions to support container
restarts without losing user sessions. This enables zero-downtime deployments
for multi-tenant platforms and Kubernetes/Docker environments.

New Features:
- exportSessionState() - Export active sessions to JSON
- restoreSessionState() - Restore sessions from exported data
- SessionState type - Serializable session structure
- Comprehensive test suite (22 tests, 100% passing)

Implementation Details:
- Only exports sessions with valid n8nApiUrl and n8nApiKey
- Automatically filters expired sessions (respects sessionTimeout)
- Validates context structure using existing validation
- Handles null/invalid sessions gracefully with warnings
- Enforces MAX_SESSIONS limit during restore (100 sessions)
- Dormant sessions recreate transport/server on first request

Files Modified:
- src/http-server-single-session.ts: Core export/restore logic
- src/mcp-engine.ts: Public API wrapper methods
- src/types/session-state.ts: Type definitions
- tests/: Comprehensive unit tests

Security Note:
Session data contains plaintext n8n API keys. Downstream applications
MUST encrypt session data before persisting to disk.

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

Co-Authored-By: Claude <noreply@anthropic.com>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

* feat: implement 7 critical session persistence API fixes for production readiness

This commit implements all 7 critical fixes identified in the code review
to make the session persistence API production-ready for zero-downtime
container deployments in multi-tenant environments.

Fixes implemented:
1. Made instanceId optional in SessionState interface
2. Removed redundant validation, properly using validateInstanceContext()
3. Fixed race condition in MAX_SESSIONS check using real-time count
4. Added comprehensive security logging with logSecurityEvent() helper
5. Added duplicate session ID detection during export with Set tracking
6. Added date parsing validation with isNaN checks for Invalid Date objects
7. Restructured null checks for proper TypeScript type narrowing

Changes:
- src/types/session-state.ts: Made instanceId optional
- src/http-server-single-session.ts: Implemented all validation and security fixes
- tests/unit/http-server/session-persistence.test.ts: Fixed MAX_SESSIONS test

All 13 session persistence unit tests passing.
All 9 MCP engine session persistence tests passing.

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2025-11-24 18:53:26 +01:00
committed by GitHub
parent 9050967cd6
commit 05424f66af
10 changed files with 1273 additions and 7 deletions

View 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' }
});
});
});
});

View 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();
});
});
});