BREAKING CHANGE(security): implement resilience and lifecycle management for RustSecurityBridge (auto-restart, health checks, state machine and eventing); remove legacy TS SMTP test helper and DNSManager; remove deliverability IP-warmup/sender-reputation integrations and related types; drop unused dependencies
This commit is contained in:
@@ -1,148 +0,0 @@
|
||||
import * as plugins from '../../ts/plugins.js';
|
||||
|
||||
export interface ITestServerConfig {
|
||||
port: number;
|
||||
hostname?: string;
|
||||
tlsEnabled?: boolean;
|
||||
authRequired?: boolean;
|
||||
timeout?: number;
|
||||
testCertPath?: string;
|
||||
testKeyPath?: string;
|
||||
maxConnections?: number;
|
||||
size?: number;
|
||||
maxRecipients?: number;
|
||||
}
|
||||
|
||||
export interface ITestServer {
|
||||
server: any;
|
||||
smtpServer: any;
|
||||
port: number;
|
||||
hostname: string;
|
||||
config: ITestServerConfig;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a test SMTP server with the given configuration.
|
||||
*
|
||||
* NOTE: The TS SMTP server implementation was removed in Phase 7B
|
||||
* (replaced by the Rust SMTP server). This stub preserves the interface
|
||||
* for smtpclient tests that import it, but those tests require `node-forge`
|
||||
* which is not installed (pre-existing issue).
|
||||
*/
|
||||
export async function startTestServer(_config: ITestServerConfig): Promise<ITestServer> {
|
||||
throw new Error(
|
||||
'startTestServer is no longer available — the TS SMTP server was removed in Phase 7B. ' +
|
||||
'Use the Rust SMTP server (via UnifiedEmailServer) for integration testing.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a test SMTP server
|
||||
*/
|
||||
export async function stopTestServer(testServer: ITestServer): Promise<void> {
|
||||
if (!testServer || !testServer.smtpServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
|
||||
await testServer.smtpServer.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping test server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an available port for testing
|
||||
*/
|
||||
export async function getAvailablePort(startPort: number = 25000): Promise<number> {
|
||||
for (let port = startPort; port < startPort + 1000; port++) {
|
||||
if (await isPortFree(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(`No available ports found starting from ${startPort}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is free
|
||||
*/
|
||||
async function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = plugins.net.createServer();
|
||||
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
|
||||
server.on('error', () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test email data
|
||||
*/
|
||||
export function createTestEmail(options: {
|
||||
from?: string;
|
||||
to?: string | string[];
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
attachments?: any[];
|
||||
} = {}): any {
|
||||
return {
|
||||
from: options.from || 'test@example.com',
|
||||
to: options.to || 'recipient@example.com',
|
||||
subject: options.subject || 'Test Email',
|
||||
text: options.text || 'This is a test email',
|
||||
html: options.html || '<p>This is a test email</p>',
|
||||
attachments: options.attachments || [],
|
||||
date: new Date(),
|
||||
messageId: `<${Date.now()}@test.example.com>`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple test server for custom protocol testing
|
||||
*/
|
||||
export interface ISimpleTestServer {
|
||||
server: any;
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export async function createTestServer(options: {
|
||||
onConnection?: (socket: any) => void | Promise<void>;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
}): Promise<ISimpleTestServer> {
|
||||
const hostname = options.hostname || 'localhost';
|
||||
const port = options.port || await getAvailablePort();
|
||||
|
||||
const server = plugins.net.createServer((socket) => {
|
||||
if (options.onConnection) {
|
||||
const result = options.onConnection(socket);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.catch(error => {
|
||||
console.error('Error in onConnection handler:', error);
|
||||
socket.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(port, hostname, () => {
|
||||
resolve({
|
||||
server,
|
||||
hostname,
|
||||
port
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
177
test/test.rustsecuritybridge.resilience.node.ts
Normal file
177
test/test.rustsecuritybridge.resilience.node.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import type { IBridgeResilienceConfig } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
|
||||
// Use fast backoff settings for testing
|
||||
const TEST_CONFIG: Partial<IBridgeResilienceConfig> = {
|
||||
maxRestartAttempts: 3,
|
||||
healthCheckIntervalMs: 60_000, // long interval so health checks don't interfere
|
||||
restartBackoffBaseMs: 100,
|
||||
restartBackoffMaxMs: 500,
|
||||
healthCheckTimeoutMs: 2_000,
|
||||
};
|
||||
|
||||
tap.test('Resilience - should start in Idle state', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
expect(bridge.state).toEqual(BridgeState.Idle);
|
||||
});
|
||||
|
||||
tap.test('Resilience - state transitions: Idle -> Starting -> Running', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
const transitions: Array<{ oldState: string; newState: string }> = [];
|
||||
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
|
||||
transitions.push(evt);
|
||||
});
|
||||
|
||||
const ok = await bridge.start();
|
||||
if (!ok) {
|
||||
console.log('WARNING: Rust binary not available — skipping resilience start tests');
|
||||
return;
|
||||
}
|
||||
|
||||
// We should have seen Idle -> Starting -> Running
|
||||
expect(transitions.length).toBeGreaterThanOrEqual(2);
|
||||
expect(transitions[0].oldState).toEqual(BridgeState.Idle);
|
||||
expect(transitions[0].newState).toEqual(BridgeState.Starting);
|
||||
expect(transitions[1].oldState).toEqual(BridgeState.Starting);
|
||||
expect(transitions[1].newState).toEqual(BridgeState.Running);
|
||||
expect(bridge.state).toEqual(BridgeState.Running);
|
||||
});
|
||||
|
||||
tap.test('Resilience - deliberate stop transitions to Stopped', async () => {
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
if (!bridge.running) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const transitions: Array<{ oldState: string; newState: string }> = [];
|
||||
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
|
||||
transitions.push(evt);
|
||||
});
|
||||
|
||||
await bridge.stop();
|
||||
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||
|
||||
// Deliberate stop should NOT trigger restart
|
||||
// Wait a bit to ensure no restart happens
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||
|
||||
bridge.removeAllListeners('stateChange');
|
||||
});
|
||||
|
||||
tap.test('Resilience - commands throw descriptive errors when not running', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
// Idle state
|
||||
try {
|
||||
await bridge.ping();
|
||||
expect(true).toBeFalse(); // Should not reach
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toInclude('not been started');
|
||||
}
|
||||
|
||||
// Stopped state
|
||||
const ok = await bridge.start();
|
||||
if (ok) {
|
||||
await bridge.stop();
|
||||
try {
|
||||
await bridge.ping();
|
||||
expect(true).toBeFalse();
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toInclude('stopped');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Resilience - restart after stop and fresh start', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
const ok = await bridge.start();
|
||||
if (!ok) {
|
||||
console.log('SKIP: Rust binary not available');
|
||||
return;
|
||||
}
|
||||
expect(bridge.state).toEqual(BridgeState.Running);
|
||||
|
||||
// Stop
|
||||
await bridge.stop();
|
||||
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||
|
||||
// Start again
|
||||
const ok2 = await bridge.start();
|
||||
expect(ok2).toBeTrue();
|
||||
expect(bridge.state).toEqual(BridgeState.Running);
|
||||
|
||||
// Commands should work
|
||||
const pong = await bridge.ping();
|
||||
expect(pong).toBeTrue();
|
||||
|
||||
await bridge.stop();
|
||||
});
|
||||
|
||||
tap.test('Resilience - stateChange events emitted correctly', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
const events: Array<{ oldState: string; newState: string }> = [];
|
||||
bridge.on('stateChange', (evt: { oldState: string; newState: string }) => {
|
||||
events.push(evt);
|
||||
});
|
||||
|
||||
const ok = await bridge.start();
|
||||
if (!ok) {
|
||||
console.log('SKIP: Rust binary not available');
|
||||
return;
|
||||
}
|
||||
|
||||
await bridge.stop();
|
||||
|
||||
// Verify the full lifecycle: Idle->Starting->Running->Stopped
|
||||
const stateSequence = events.map(e => e.newState);
|
||||
expect(stateSequence).toContain(BridgeState.Starting);
|
||||
expect(stateSequence).toContain(BridgeState.Running);
|
||||
expect(stateSequence).toContain(BridgeState.Stopped);
|
||||
|
||||
bridge.removeAllListeners('stateChange');
|
||||
});
|
||||
|
||||
tap.test('Resilience - configure sets resilience parameters', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure({
|
||||
maxRestartAttempts: 10,
|
||||
healthCheckIntervalMs: 60_000,
|
||||
});
|
||||
// Just verify no errors — config is private, but we can verify
|
||||
// by the behavior in other tests
|
||||
const bridge = RustSecurityBridge.getInstance();
|
||||
expect(bridge).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('Resilience - resetInstance creates fresh singleton', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
const bridge1 = RustSecurityBridge.getInstance();
|
||||
RustSecurityBridge.resetInstance();
|
||||
const bridge2 = RustSecurityBridge.getInstance();
|
||||
// They should be different instances (we can't compare directly since
|
||||
// resetInstance nulls the static, and getInstance creates new)
|
||||
expect(bridge2.state).toEqual(BridgeState.Idle);
|
||||
});
|
||||
|
||||
tap.test('Resilience - cleanup', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
RustSecurityBridge.configure(TEST_CONFIG);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user