update
This commit is contained in:
@ -0,0 +1,344 @@
|
||||
import * as plugins from '@git.zone/tstest/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
||||
|
||||
const TEST_PORT = 2525;
|
||||
|
||||
let testServer;
|
||||
|
||||
tap.test('prepare server', async () => {
|
||||
testServer = await startTestServer({ port: TEST_PORT });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
tap.test('REL-01: Long-running operation - Continuous email sending', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const testDuration = 30000; // 30 seconds
|
||||
const operationInterval = 2000; // 2 seconds between operations
|
||||
const startTime = Date.now();
|
||||
const endTime = startTime + testDuration;
|
||||
|
||||
let operations = 0;
|
||||
let successful = 0;
|
||||
let errors = 0;
|
||||
let connectionIssues = 0;
|
||||
const operationResults: Array<{
|
||||
operation: number;
|
||||
success: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
}> = [];
|
||||
|
||||
console.log(`Running long-duration test for ${testDuration/1000} seconds...`);
|
||||
|
||||
const performOperation = async (operationId: number): Promise<void> => {
|
||||
const operationStart = Date.now();
|
||||
operations++;
|
||||
|
||||
try {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const result = await new Promise<{ success: boolean; error?: string; connectionIssue?: boolean }>((resolve) => {
|
||||
let step = 'connecting';
|
||||
let receivedData = '';
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Timeout in step ${step}`,
|
||||
connectionIssue: true
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
socket.on('connect', () => {
|
||||
step = 'connected';
|
||||
});
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
receivedData += chunk.toString();
|
||||
const lines = receivedData.split('\r\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
// Check for errors
|
||||
if (line.match(/^[45]\d\d\s/)) {
|
||||
clearTimeout(timeout);
|
||||
socket.destroy();
|
||||
resolve({
|
||||
success: false,
|
||||
error: `SMTP error in ${step}: ${line}`,
|
||||
connectionIssue: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process responses
|
||||
if (step === 'connected' && line.startsWith('220')) {
|
||||
step = 'ehlo';
|
||||
socket.write(`EHLO longrun-${operationId}\r\n`);
|
||||
} else if (step === 'ehlo' && line.includes('250 ') && !line.includes('250-')) {
|
||||
step = 'mail_from';
|
||||
socket.write(`MAIL FROM:<sender${operationId}@example.com>\r\n`);
|
||||
} else if (step === 'mail_from' && line.startsWith('250')) {
|
||||
step = 'rcpt_to';
|
||||
socket.write(`RCPT TO:<recipient${operationId}@example.com>\r\n`);
|
||||
} else if (step === 'rcpt_to' && line.startsWith('250')) {
|
||||
step = 'data';
|
||||
socket.write('DATA\r\n');
|
||||
} else if (step === 'data' && line.startsWith('354')) {
|
||||
step = 'email_content';
|
||||
const emailContent = [
|
||||
`From: sender${operationId}@example.com`,
|
||||
`To: recipient${operationId}@example.com`,
|
||||
`Subject: Long Running Test Operation ${operationId}`,
|
||||
`Date: ${new Date().toUTCString()}`,
|
||||
'',
|
||||
`This is test operation ${operationId} for long-running reliability testing.`,
|
||||
`Timestamp: ${Date.now()}`,
|
||||
'.',
|
||||
''
|
||||
].join('\r\n');
|
||||
socket.write(emailContent);
|
||||
} else if (step === 'email_content' && line.startsWith('250')) {
|
||||
step = 'quit';
|
||||
socket.write('QUIT\r\n');
|
||||
} else if (step === 'quit' && line.startsWith('221')) {
|
||||
clearTimeout(timeout);
|
||||
socket.end();
|
||||
resolve({
|
||||
success: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: error.message,
|
||||
connectionIssue: true
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
if (step !== 'quit') {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: 'Connection closed unexpectedly',
|
||||
connectionIssue: true
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const duration = Date.now() - operationStart;
|
||||
|
||||
if (result.success) {
|
||||
successful++;
|
||||
} else {
|
||||
errors++;
|
||||
if (result.connectionIssue) {
|
||||
connectionIssues++;
|
||||
}
|
||||
}
|
||||
|
||||
operationResults.push({
|
||||
operation: operationId,
|
||||
success: result.success,
|
||||
duration,
|
||||
error: result.error,
|
||||
timestamp: operationStart
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
errors++;
|
||||
operationResults.push({
|
||||
operation: operationId,
|
||||
success: false,
|
||||
duration: Date.now() - operationStart,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: operationStart
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Run operations continuously until end time
|
||||
while (Date.now() < endTime) {
|
||||
const operationStart = Date.now();
|
||||
|
||||
await performOperation(operations + 1);
|
||||
|
||||
// Calculate wait time for next operation
|
||||
const nextOperation = operationStart + operationInterval;
|
||||
const waitTime = nextOperation - Date.now();
|
||||
|
||||
if (waitTime > 0 && Date.now() < endTime) {
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
}
|
||||
|
||||
// Progress update every 5 operations
|
||||
if (operations % 5 === 0) {
|
||||
console.log(`Progress: ${operations} operations, ${successful} successful, ${errors} errors`);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate results
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const successRate = successful / operations;
|
||||
const connectionIssueRate = connectionIssues / operations;
|
||||
const avgOperationTime = operationResults.reduce((sum, r) => sum + r.duration, 0) / operations;
|
||||
|
||||
console.log(`\nLong-Running Operation Results:`);
|
||||
console.log(`Total duration: ${(totalDuration/1000).toFixed(1)}s`);
|
||||
console.log(`Total operations: ${operations}`);
|
||||
console.log(`Successful: ${successful} (${(successRate * 100).toFixed(1)}%)`);
|
||||
console.log(`Errors: ${errors}`);
|
||||
console.log(`Connection issues: ${connectionIssues} (${(connectionIssueRate * 100).toFixed(1)}%)`);
|
||||
console.log(`Average operation time: ${avgOperationTime.toFixed(0)}ms`);
|
||||
|
||||
// Show last few operations for debugging
|
||||
console.log('\nLast 5 operations:');
|
||||
operationResults.slice(-5).forEach(op => {
|
||||
console.log(` Op ${op.operation}: ${op.success ? 'success' : 'failed'} (${op.duration}ms)${op.error ? ' - ' + op.error : ''}`);
|
||||
});
|
||||
|
||||
// Test passes with 85% success rate and max 10% connection issues
|
||||
expect(successRate).toBeGreaterThanOrEqual(0.85);
|
||||
expect(connectionIssueRate).toBeLessThanOrEqual(0.1);
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-01: Long-running operation - Server stability check', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const checkDuration = 15000; // 15 seconds
|
||||
const checkInterval = 3000; // 3 seconds between checks
|
||||
const startTime = Date.now();
|
||||
const endTime = startTime + checkDuration;
|
||||
|
||||
const stabilityChecks: Array<{
|
||||
timestamp: number;
|
||||
responseTime: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
console.log(`\nRunning server stability checks for ${checkDuration/1000} seconds...`);
|
||||
|
||||
try {
|
||||
while (Date.now() < endTime) {
|
||||
const checkStart = Date.now();
|
||||
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const checkResult = await new Promise<{ success: boolean; responseTime: number; error?: string }>((resolve) => {
|
||||
const connectTime = Date.now();
|
||||
let greetingReceived = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve({
|
||||
success: false,
|
||||
responseTime: Date.now() - connectTime,
|
||||
error: 'Timeout waiting for greeting'
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
socket.on('connect', () => {
|
||||
// Connected
|
||||
});
|
||||
|
||||
socket.once('data', (chunk) => {
|
||||
const response = chunk.toString();
|
||||
clearTimeout(timeout);
|
||||
greetingReceived = true;
|
||||
|
||||
if (response.startsWith('220')) {
|
||||
socket.write('QUIT\r\n');
|
||||
socket.end();
|
||||
resolve({
|
||||
success: true,
|
||||
responseTime: Date.now() - connectTime
|
||||
});
|
||||
} else {
|
||||
socket.end();
|
||||
resolve({
|
||||
success: false,
|
||||
responseTime: Date.now() - connectTime,
|
||||
error: `Unexpected greeting: ${response.substring(0, 50)}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
success: false,
|
||||
responseTime: Date.now() - connectTime,
|
||||
error: error.message
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
stabilityChecks.push({
|
||||
timestamp: checkStart,
|
||||
responseTime: checkResult.responseTime,
|
||||
success: checkResult.success,
|
||||
error: checkResult.error
|
||||
});
|
||||
|
||||
console.log(`Stability check ${stabilityChecks.length}: ${checkResult.success ? 'OK' : 'FAILED'} (${checkResult.responseTime}ms)`);
|
||||
|
||||
// Wait for next check
|
||||
const nextCheck = checkStart + checkInterval;
|
||||
const waitTime = nextCheck - Date.now();
|
||||
if (waitTime > 0 && Date.now() < endTime) {
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze stability
|
||||
const successfulChecks = stabilityChecks.filter(c => c.success).length;
|
||||
const avgResponseTime = stabilityChecks
|
||||
.filter(c => c.success)
|
||||
.reduce((sum, c) => sum + c.responseTime, 0) / successfulChecks || 0;
|
||||
const maxResponseTime = Math.max(...stabilityChecks.filter(c => c.success).map(c => c.responseTime));
|
||||
|
||||
console.log(`\nStability Check Results:`);
|
||||
console.log(`Total checks: ${stabilityChecks.length}`);
|
||||
console.log(`Successful: ${successfulChecks} (${(successfulChecks/stabilityChecks.length * 100).toFixed(1)}%)`);
|
||||
console.log(`Average response time: ${avgResponseTime.toFixed(0)}ms`);
|
||||
console.log(`Max response time: ${maxResponseTime}ms`);
|
||||
|
||||
// All checks should succeed for stable server
|
||||
expect(successfulChecks).toEqual(stabilityChecks.length);
|
||||
expect(avgResponseTime).toBeLessThan(1000);
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup server', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.start();
|
@ -0,0 +1,328 @@
|
||||
import * as plugins from '@git.zone/tstest/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
||||
|
||||
const TEST_PORT = 2525;
|
||||
|
||||
let testServer;
|
||||
|
||||
// Helper function to wait for SMTP response
|
||||
const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => {
|
||||
socket.removeListener('data', handler);
|
||||
reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`));
|
||||
}, timeout);
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\r\n');
|
||||
|
||||
// Check if we have a complete response
|
||||
for (const line of lines) {
|
||||
if (expectedCode) {
|
||||
if (line.startsWith(expectedCode + ' ')) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Any complete response line
|
||||
if (line.match(/^\d{3} /)) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', handler);
|
||||
});
|
||||
};
|
||||
|
||||
tap.test('prepare server', async () => {
|
||||
testServer = await startTestServer({ port: TEST_PORT });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
tap.test('REL-02: Restart recovery - Server state after restart', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
console.log('Testing server state and recovery capabilities...');
|
||||
|
||||
// First, establish that server is working normally
|
||||
const socket1 = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket1.once('connect', resolve);
|
||||
socket1.once('error', reject);
|
||||
});
|
||||
|
||||
// Read greeting
|
||||
const greeting1 = await waitForResponse(socket1, '220');
|
||||
expect(greeting1).toInclude('220');
|
||||
console.log('Initial connection successful');
|
||||
|
||||
// Send EHLO
|
||||
socket1.write('EHLO testhost\r\n');
|
||||
await waitForResponse(socket1, '250');
|
||||
|
||||
// Complete a transaction
|
||||
socket1.write('MAIL FROM:<sender@example.com>\r\n');
|
||||
const mailResp1 = await waitForResponse(socket1, '250');
|
||||
expect(mailResp1).toInclude('250');
|
||||
|
||||
socket1.write('RCPT TO:<recipient@example.com>\r\n');
|
||||
const rcptResp1 = await waitForResponse(socket1, '250');
|
||||
expect(rcptResp1).toInclude('250');
|
||||
|
||||
socket1.write('DATA\r\n');
|
||||
const dataResp1 = await waitForResponse(socket1, '354');
|
||||
expect(dataResp1).toInclude('354');
|
||||
|
||||
const emailContent = [
|
||||
'From: sender@example.com',
|
||||
'To: recipient@example.com',
|
||||
'Subject: Pre-restart test',
|
||||
'',
|
||||
'Testing server state before restart.',
|
||||
'.',
|
||||
''
|
||||
].join('\r\n');
|
||||
|
||||
socket1.write(emailContent);
|
||||
const sendResp1 = await waitForResponse(socket1, '250');
|
||||
expect(sendResp1).toInclude('250');
|
||||
|
||||
socket1.write('QUIT\r\n');
|
||||
await waitForResponse(socket1, '221');
|
||||
socket1.end();
|
||||
|
||||
console.log('Pre-restart transaction completed successfully');
|
||||
|
||||
// Simulate server restart by closing and reopening connections
|
||||
console.log('\nSimulating server restart scenario...');
|
||||
|
||||
// Wait a moment to simulate restart time
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Test recovery after simulated restart
|
||||
const socket2 = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket2.once('connect', resolve);
|
||||
socket2.once('error', reject);
|
||||
});
|
||||
|
||||
// Read greeting after "restart"
|
||||
const greeting2 = await new Promise<string>((resolve) => {
|
||||
socket2.once('data', (chunk) => {
|
||||
resolve(chunk.toString());
|
||||
});
|
||||
});
|
||||
|
||||
expect(greeting2).toInclude('220');
|
||||
console.log('Post-restart connection successful');
|
||||
|
||||
// Verify server is fully functional after restart
|
||||
socket2.write('EHLO testhost-postrestart\r\n');
|
||||
await waitForResponse(socket2, '250');
|
||||
|
||||
// Complete another transaction to verify full recovery
|
||||
socket2.write('MAIL FROM:<sender2@example.com>\r\n');
|
||||
const mailResp2 = await waitForResponse(socket2, '250');
|
||||
expect(mailResp2).toInclude('250');
|
||||
|
||||
socket2.write('RCPT TO:<recipient2@example.com>\r\n');
|
||||
const rcptResp2 = await waitForResponse(socket2, '250');
|
||||
expect(rcptResp2).toInclude('250');
|
||||
|
||||
socket2.write('DATA\r\n');
|
||||
const dataResp2 = await waitForResponse(socket2, '354');
|
||||
expect(dataResp2).toInclude('354');
|
||||
|
||||
const postRestartEmail = [
|
||||
'From: sender2@example.com',
|
||||
'To: recipient2@example.com',
|
||||
'Subject: Post-restart recovery test',
|
||||
'',
|
||||
'Testing server recovery after restart.',
|
||||
'.',
|
||||
''
|
||||
].join('\r\n');
|
||||
|
||||
socket2.write(postRestartEmail);
|
||||
const sendResp2 = await waitForResponse(socket2, '250');
|
||||
expect(sendResp2).toInclude('250');
|
||||
|
||||
socket2.write('QUIT\r\n');
|
||||
await waitForResponse(socket2, '221');
|
||||
socket2.end();
|
||||
|
||||
console.log('Post-restart transaction completed successfully');
|
||||
console.log('Server recovered successfully from restart');
|
||||
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-02: Restart recovery - Multiple rapid reconnections', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const rapidConnections = 10;
|
||||
let successfulReconnects = 0;
|
||||
|
||||
try {
|
||||
console.log(`\nTesting rapid reconnection after disruption (${rapidConnections} attempts)...`);
|
||||
|
||||
for (let i = 0; i < rapidConnections; i++) {
|
||||
try {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 5000);
|
||||
|
||||
socket.once('connect', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
socket.once('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
// Read greeting
|
||||
try {
|
||||
const greeting = await waitForResponse(socket, '220', 3000);
|
||||
if (greeting.includes('220')) {
|
||||
successfulReconnects++;
|
||||
socket.write('QUIT\r\n');
|
||||
await waitForResponse(socket, '221', 1000).catch(() => {});
|
||||
socket.end();
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
socket.destroy();
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Very short delay between attempts
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
} catch (error) {
|
||||
console.log(`Reconnection ${i + 1} failed:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const reconnectRate = successfulReconnects / rapidConnections;
|
||||
console.log(`Successful reconnections: ${successfulReconnects}/${rapidConnections} (${(reconnectRate * 100).toFixed(1)}%)`);
|
||||
|
||||
// Expect high success rate for good recovery
|
||||
expect(reconnectRate).toBeGreaterThanOrEqual(0.8);
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-02: Restart recovery - State persistence check', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
console.log('\nTesting server state persistence across connections...');
|
||||
|
||||
// Create initial connection and start transaction
|
||||
const socket1 = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket1.once('connect', resolve);
|
||||
socket1.once('error', reject);
|
||||
});
|
||||
|
||||
// Read greeting
|
||||
await waitForResponse(socket1, '220');
|
||||
|
||||
// Send EHLO
|
||||
socket1.write('EHLO persistence-test\r\n');
|
||||
await waitForResponse(socket1, '250');
|
||||
|
||||
// Start transaction but don't complete it
|
||||
socket1.write('MAIL FROM:<incomplete@example.com>\r\n');
|
||||
const mailResp = await waitForResponse(socket1, '250');
|
||||
expect(mailResp).toInclude('250');
|
||||
|
||||
// Abruptly close connection
|
||||
socket1.destroy();
|
||||
console.log('Abruptly closed connection with incomplete transaction');
|
||||
|
||||
// Wait briefly
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Create new connection and verify server recovered
|
||||
const socket2 = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket2.once('connect', resolve);
|
||||
socket2.once('error', reject);
|
||||
});
|
||||
|
||||
// Read greeting
|
||||
await waitForResponse(socket2, '220');
|
||||
|
||||
// Send EHLO
|
||||
socket2.write('EHLO recovery-test\r\n');
|
||||
await waitForResponse(socket2, '250');
|
||||
|
||||
// Try new transaction - should work without issues from previous incomplete one
|
||||
socket2.write('MAIL FROM:<recovery@example.com>\r\n');
|
||||
const mailResponse = await waitForResponse(socket2, '250');
|
||||
expect(mailResponse).toInclude('250');
|
||||
console.log('Server recovered successfully - new transaction started without issues');
|
||||
|
||||
socket2.write('QUIT\r\n');
|
||||
await waitForResponse(socket2, '221');
|
||||
socket2.end();
|
||||
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup server', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.start();
|
@ -0,0 +1,394 @@
|
||||
import * as plugins from '@git.zone/tstest/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
||||
|
||||
const TEST_PORT = 2525;
|
||||
|
||||
let testServer;
|
||||
|
||||
interface ResourceMetrics {
|
||||
timestamp: number;
|
||||
memoryUsage: {
|
||||
rss: number;
|
||||
heapTotal: number;
|
||||
heapUsed: number;
|
||||
external: number;
|
||||
};
|
||||
processInfo: {
|
||||
pid: number;
|
||||
uptime: number;
|
||||
cpuUsage: NodeJS.CpuUsage;
|
||||
};
|
||||
}
|
||||
|
||||
interface LeakAnalysis {
|
||||
memoryGrowthMB: number;
|
||||
memoryTrend: number;
|
||||
stabilityScore: number;
|
||||
memoryLeakDetected: boolean;
|
||||
resourcesStable: boolean;
|
||||
samplesAnalyzed: number;
|
||||
initialMemoryMB: number;
|
||||
finalMemoryMB: number;
|
||||
}
|
||||
|
||||
const captureResourceMetrics = async (): Promise<ResourceMetrics> => {
|
||||
// Force GC if available before measurement
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const memUsage = process.memoryUsage();
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
memoryUsage: {
|
||||
rss: Math.round(memUsage.rss / 1024 / 1024 * 100) / 100,
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024 * 100) / 100,
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024 * 100) / 100,
|
||||
external: Math.round(memUsage.external / 1024 / 1024 * 100) / 100
|
||||
},
|
||||
processInfo: {
|
||||
pid: process.pid,
|
||||
uptime: process.uptime(),
|
||||
cpuUsage: process.cpuUsage()
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to wait for SMTP response
|
||||
const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => {
|
||||
socket.removeListener('data', handler);
|
||||
reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`));
|
||||
}, timeout);
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\r\n');
|
||||
|
||||
// Check if we have a complete response
|
||||
for (const line of lines) {
|
||||
if (expectedCode) {
|
||||
if (line.startsWith(expectedCode + ' ')) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Any complete response line
|
||||
if (line.match(/^\d{3} /)) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', handler);
|
||||
});
|
||||
};
|
||||
|
||||
const analyzeResourceLeaks = (initial: ResourceMetrics, samples: Array<{ operation: number; metrics: ResourceMetrics }>, final: ResourceMetrics): LeakAnalysis => {
|
||||
const memoryGrowthMB = final.memoryUsage.heapUsed - initial.memoryUsage.heapUsed;
|
||||
|
||||
// Analyze memory trend over samples
|
||||
let memoryTrend = 0;
|
||||
if (samples.length > 1) {
|
||||
const firstSample = samples[0].metrics.memoryUsage.heapUsed;
|
||||
const lastSample = samples[samples.length - 1].metrics.memoryUsage.heapUsed;
|
||||
memoryTrend = lastSample - firstSample;
|
||||
}
|
||||
|
||||
// Calculate stability score based on memory variance
|
||||
let stabilityScore = 1.0;
|
||||
if (samples.length > 2) {
|
||||
const memoryValues = samples.map(s => s.metrics.memoryUsage.heapUsed);
|
||||
const average = memoryValues.reduce((a, b) => a + b, 0) / memoryValues.length;
|
||||
const variance = memoryValues.reduce((acc, val) => acc + Math.pow(val - average, 2), 0) / memoryValues.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
stabilityScore = Math.max(0, 1 - (stdDev / average));
|
||||
}
|
||||
|
||||
return {
|
||||
memoryGrowthMB: Math.round(memoryGrowthMB * 100) / 100,
|
||||
memoryTrend: Math.round(memoryTrend * 100) / 100,
|
||||
stabilityScore: Math.round(stabilityScore * 100) / 100,
|
||||
memoryLeakDetected: memoryGrowthMB > 50,
|
||||
resourcesStable: stabilityScore > 0.8 && memoryGrowthMB < 25,
|
||||
samplesAnalyzed: samples.length,
|
||||
initialMemoryMB: initial.memoryUsage.heapUsed,
|
||||
finalMemoryMB: final.memoryUsage.heapUsed
|
||||
};
|
||||
};
|
||||
|
||||
tap.test('prepare server', async () => {
|
||||
testServer = await startTestServer({ port: TEST_PORT });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
tap.test('REL-03: Resource leak detection - Memory leak analysis', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const operationCount = 20;
|
||||
const connections: net.Socket[] = [];
|
||||
const samples: Array<{ operation: number; metrics: ResourceMetrics }> = [];
|
||||
|
||||
try {
|
||||
const initialMetrics = await captureResourceMetrics();
|
||||
console.log(`📊 Initial memory: ${initialMetrics.memoryUsage.heapUsed}MB`);
|
||||
|
||||
for (let i = 0; i < operationCount; i++) {
|
||||
console.log(`🔄 Operation ${i + 1}/${operationCount}...`);
|
||||
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
connections.push(socket);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
// Read greeting
|
||||
await waitForResponse(socket, '220');
|
||||
|
||||
// Send EHLO
|
||||
socket.write(`EHLO leaktest-${i}\r\n`);
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
// Complete email transaction
|
||||
socket.write(`MAIL FROM:<sender${i}@example.com>\r\n`);
|
||||
const mailResp = await waitForResponse(socket, '250');
|
||||
expect(mailResp).toInclude('250');
|
||||
|
||||
socket.write(`RCPT TO:<recipient${i}@example.com>\r\n`);
|
||||
const rcptResp = await waitForResponse(socket, '250');
|
||||
expect(rcptResp).toInclude('250');
|
||||
|
||||
socket.write('DATA\r\n');
|
||||
const dataResp = await waitForResponse(socket, '354');
|
||||
expect(dataResp).toInclude('354');
|
||||
|
||||
const emailContent = [
|
||||
`From: sender${i}@example.com`,
|
||||
`To: recipient${i}@example.com`,
|
||||
`Subject: Resource Leak Test ${i + 1}`,
|
||||
`Message-ID: <leak-test-${i}-${Date.now()}@example.com>`,
|
||||
'',
|
||||
`This is resource leak test iteration ${i + 1}.`,
|
||||
'.',
|
||||
''
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(emailContent);
|
||||
const sendResp = await waitForResponse(socket, '250');
|
||||
expect(sendResp).toInclude('250');
|
||||
|
||||
socket.write('QUIT\r\n');
|
||||
await waitForResponse(socket, '221');
|
||||
socket.end();
|
||||
|
||||
// Capture metrics every 5 operations
|
||||
if ((i + 1) % 5 === 0) {
|
||||
const metrics = await captureResourceMetrics();
|
||||
samples.push({
|
||||
operation: i + 1,
|
||||
metrics
|
||||
});
|
||||
console.log(`📈 Sample ${samples.length}: Memory ${metrics.memoryUsage.heapUsed}MB`);
|
||||
}
|
||||
|
||||
// Small delay between operations
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// Clean up all connections
|
||||
connections.forEach(conn => {
|
||||
if (!conn.destroyed) {
|
||||
conn.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
const finalMetrics = await captureResourceMetrics();
|
||||
const leakAnalysis = analyzeResourceLeaks(initialMetrics, samples, finalMetrics);
|
||||
|
||||
console.log('\n📊 Resource Leak Analysis:');
|
||||
console.log(`Initial memory: ${leakAnalysis.initialMemoryMB}MB`);
|
||||
console.log(`Final memory: ${leakAnalysis.finalMemoryMB}MB`);
|
||||
console.log(`Memory growth: ${leakAnalysis.memoryGrowthMB}MB`);
|
||||
console.log(`Memory trend: ${leakAnalysis.memoryTrend}MB`);
|
||||
console.log(`Stability score: ${leakAnalysis.stabilityScore}`);
|
||||
console.log(`Memory leak detected: ${leakAnalysis.memoryLeakDetected}`);
|
||||
console.log(`Resources stable: ${leakAnalysis.resourcesStable}`);
|
||||
|
||||
expect(leakAnalysis.memoryLeakDetected).toEqual(false);
|
||||
expect(leakAnalysis.resourcesStable).toEqual(true);
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
connections.forEach(conn => conn.destroy());
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-03: Resource leak detection - Connection leak test', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const abandonedConnections: net.Socket[] = [];
|
||||
|
||||
try {
|
||||
console.log('\nTesting for connection resource leaks...');
|
||||
|
||||
// Create connections that are abandoned without proper cleanup
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
abandonedConnections.push(socket);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
// Read greeting but don't complete transaction
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.once('data', () => resolve());
|
||||
});
|
||||
|
||||
// Start but don't complete EHLO
|
||||
socket.write(`EHLO abandoned-${i}\r\n`);
|
||||
|
||||
// Don't wait for response, just move to next
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
console.log('Created 10 abandoned connections');
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Try to create new connections - should still work
|
||||
let newConnectionsSuccessful = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 5000);
|
||||
|
||||
socket.once('connect', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
socket.once('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
// Verify connection works
|
||||
const greeting = await new Promise<string>((resolve) => {
|
||||
socket.once('data', (chunk) => {
|
||||
resolve(chunk.toString());
|
||||
});
|
||||
});
|
||||
|
||||
if (greeting.includes('220')) {
|
||||
newConnectionsSuccessful++;
|
||||
socket.write('QUIT\r\n');
|
||||
socket.end();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`New connection ${i + 1} failed:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up abandoned connections
|
||||
abandonedConnections.forEach(conn => conn.destroy());
|
||||
|
||||
console.log(`New connections successful: ${newConnectionsSuccessful}/5`);
|
||||
|
||||
// Server should still accept new connections despite abandoned ones
|
||||
expect(newConnectionsSuccessful).toBeGreaterThanOrEqual(4);
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
abandonedConnections.forEach(conn => conn.destroy());
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-03: Resource leak detection - Rapid create/destroy cycles', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const cycles = 30;
|
||||
const initialMetrics = await captureResourceMetrics();
|
||||
|
||||
try {
|
||||
console.log('\nTesting rapid connection create/destroy cycles...');
|
||||
|
||||
for (let i = 0; i < cycles; i++) {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
// Immediately destroy after connect
|
||||
socket.destroy();
|
||||
|
||||
// Very short delay
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
if ((i + 1) % 10 === 0) {
|
||||
console.log(`Completed ${i + 1} cycles`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for resources to be released
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
const finalMetrics = await captureResourceMetrics();
|
||||
const memoryGrowth = finalMetrics.memoryUsage.heapUsed - initialMetrics.memoryUsage.heapUsed;
|
||||
|
||||
console.log(`Memory growth after ${cycles} cycles: ${memoryGrowth.toFixed(2)}MB`);
|
||||
|
||||
// Memory growth should be minimal
|
||||
expect(memoryGrowth).toBeLessThan(10);
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup server', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.start();
|
401
test/suite/smtpserver_reliability/test.rel-04.error-recovery.ts
Normal file
401
test/suite/smtpserver_reliability/test.rel-04.error-recovery.ts
Normal file
@ -0,0 +1,401 @@
|
||||
import * as plugins from '@git.zone/tstest/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
||||
|
||||
const TEST_PORT = 2525;
|
||||
|
||||
let testServer;
|
||||
|
||||
const createConnection = async (): Promise<net.Socket> => {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
// Helper function to wait for SMTP response
|
||||
const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => {
|
||||
socket.removeListener('data', handler);
|
||||
reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`));
|
||||
}, timeout);
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\r\n');
|
||||
|
||||
// Check if we have a complete response
|
||||
for (const line of lines) {
|
||||
if (expectedCode) {
|
||||
if (line.startsWith(expectedCode + ' ')) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Any complete response line
|
||||
if (line.match(/^\d{3} /)) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', handler);
|
||||
});
|
||||
};
|
||||
|
||||
const getResponse = waitForResponse;
|
||||
|
||||
const testBasicSmtpFlow = async (socket: net.Socket): Promise<boolean> => {
|
||||
try {
|
||||
// Read greeting
|
||||
await waitForResponse(socket, '220');
|
||||
|
||||
// Send EHLO
|
||||
socket.write('EHLO recovery-test\r\n');
|
||||
const ehloResp = await waitForResponse(socket, '250');
|
||||
if (!ehloResp.includes('250')) return false;
|
||||
|
||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
||||
const mailResp = await waitForResponse(socket, '250');
|
||||
if (!mailResp.includes('250')) return false;
|
||||
|
||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
||||
const rcptResp = await waitForResponse(socket, '250');
|
||||
if (!rcptResp.includes('250')) return false;
|
||||
|
||||
socket.write('DATA\r\n');
|
||||
const dataResp = await waitForResponse(socket, '354');
|
||||
if (!dataResp.includes('354')) return false;
|
||||
|
||||
const testEmail = [
|
||||
'From: sender@example.com',
|
||||
'To: recipient@example.com',
|
||||
'Subject: Recovery Test Email',
|
||||
'',
|
||||
'This email tests server recovery.',
|
||||
'.',
|
||||
''
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(testEmail);
|
||||
const finalResp = await waitForResponse(socket, '250');
|
||||
|
||||
socket.write('QUIT\r\n');
|
||||
socket.end();
|
||||
|
||||
return finalResp.includes('250');
|
||||
} catch (error) {
|
||||
console.log('Basic SMTP flow error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
tap.test('prepare server', async () => {
|
||||
testServer = await startTestServer({ port: TEST_PORT });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
tap.test('REL-04: Error recovery - Invalid command recovery', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
console.log('Testing recovery from invalid commands...');
|
||||
|
||||
// Phase 1: Send invalid commands
|
||||
const socket1 = await createConnection();
|
||||
await waitForResponse(socket1, '220');
|
||||
|
||||
// Send multiple invalid commands
|
||||
socket1.write('INVALID_COMMAND\r\n');
|
||||
const response1 = await waitForResponse(socket1);
|
||||
expect(response1).toMatch(/50[0-3]/); // Should get error response
|
||||
|
||||
socket1.write('ANOTHER_INVALID\r\n');
|
||||
const response2 = await waitForResponse(socket1);
|
||||
expect(response2).toMatch(/50[0-3]/);
|
||||
|
||||
socket1.write('YET_ANOTHER_BAD_CMD\r\n');
|
||||
const response3 = await waitForResponse(socket1);
|
||||
expect(response3).toMatch(/50[0-3]/);
|
||||
|
||||
socket1.end();
|
||||
|
||||
// Phase 2: Test recovery - server should still work normally
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const socket2 = await createConnection();
|
||||
const recoverySuccess = await testBasicSmtpFlow(socket2);
|
||||
|
||||
expect(recoverySuccess).toEqual(true);
|
||||
console.log('✓ Server recovered from invalid commands');
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-04: Error recovery - Malformed data recovery', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
console.log('\nTesting recovery from malformed data...');
|
||||
|
||||
// Phase 1: Send malformed data
|
||||
const socket1 = await createConnection();
|
||||
await waitForResponse(socket1, '220');
|
||||
|
||||
socket1.write('EHLO testhost\r\n');
|
||||
await waitForResponse(socket1, '250');
|
||||
|
||||
// Send malformed MAIL FROM
|
||||
socket1.write('MAIL FROM: invalid-format\r\n');
|
||||
const response1 = await waitForResponse(socket1);
|
||||
expect(response1).toMatch(/50[0-3]/);
|
||||
|
||||
// Send malformed RCPT TO
|
||||
socket1.write('RCPT TO: also-invalid\r\n');
|
||||
const response2 = await waitForResponse(socket1);
|
||||
expect(response2).toMatch(/50[0-3]/);
|
||||
|
||||
// Send malformed DATA with binary
|
||||
socket1.write('DATA\x00\x01\x02CORRUPTED\r\n');
|
||||
const response3 = await waitForResponse(socket1);
|
||||
expect(response3).toMatch(/50[0-3]/);
|
||||
|
||||
socket1.end();
|
||||
|
||||
// Phase 2: Test recovery
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const socket2 = await createConnection();
|
||||
const recoverySuccess = await testBasicSmtpFlow(socket2);
|
||||
|
||||
expect(recoverySuccess).toEqual(true);
|
||||
console.log('✓ Server recovered from malformed data');
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-04: Error recovery - Premature disconnection recovery', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
console.log('\nTesting recovery from premature disconnection...');
|
||||
|
||||
// Phase 1: Create incomplete transactions
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const socket = await createConnection();
|
||||
await waitForResponse(socket, '220');
|
||||
|
||||
socket.write('EHLO abrupt-test\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
socket.write('MAIL FROM:<test@example.com>\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
// Abruptly close connection during transaction
|
||||
socket.destroy();
|
||||
console.log(` Abruptly closed connection ${i + 1}`);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
// Phase 2: Test recovery
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const socket2 = await createConnection();
|
||||
const recoverySuccess = await testBasicSmtpFlow(socket2);
|
||||
|
||||
expect(recoverySuccess).toEqual(true);
|
||||
console.log('✓ Server recovered from premature disconnections');
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-04: Error recovery - Data corruption recovery', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
console.log('\nTesting recovery from data corruption...');
|
||||
|
||||
const socket1 = await createConnection();
|
||||
await waitForResponse(socket1, '220');
|
||||
|
||||
socket1.write('EHLO corruption-test\r\n');
|
||||
await waitForResponse(socket1, '250');
|
||||
|
||||
socket1.write('MAIL FROM:<sender@example.com>\r\n');
|
||||
await waitForResponse(socket1, '250');
|
||||
|
||||
socket1.write('RCPT TO:<recipient@example.com>\r\n');
|
||||
await waitForResponse(socket1, '250');
|
||||
|
||||
socket1.write('DATA\r\n');
|
||||
const dataResp = await waitForResponse(socket1, '354');
|
||||
expect(dataResp).toInclude('354');
|
||||
|
||||
// Send corrupted email data with null bytes and invalid characters
|
||||
socket1.write('From: test\r\n\0\0\0CORRUPTED DATA\xff\xfe\r\n');
|
||||
socket1.write('Subject: \x01\x02\x03Invalid\r\n');
|
||||
socket1.write('\r\n');
|
||||
socket1.write('Body with \0null bytes\r\n');
|
||||
socket1.write('.\r\n');
|
||||
|
||||
try {
|
||||
const response = await waitForResponse(socket1);
|
||||
console.log(' Server response to corrupted data:', response.substring(0, 50));
|
||||
} catch (error) {
|
||||
console.log(' Server rejected corrupted data (expected)');
|
||||
}
|
||||
|
||||
socket1.end();
|
||||
|
||||
// Phase 2: Test recovery
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const socket2 = await createConnection();
|
||||
const recoverySuccess = await testBasicSmtpFlow(socket2);
|
||||
|
||||
expect(recoverySuccess).toEqual(true);
|
||||
console.log('✓ Server recovered from data corruption');
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-04: Error recovery - Connection flooding recovery', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const connections: net.Socket[] = [];
|
||||
|
||||
try {
|
||||
console.log('\nTesting recovery from connection flooding...');
|
||||
|
||||
// Phase 1: Create multiple rapid connections
|
||||
console.log(' Creating 15 rapid connections...');
|
||||
for (let i = 0; i < 15; i++) {
|
||||
try {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 2000
|
||||
});
|
||||
connections.push(socket);
|
||||
|
||||
// Don't wait for connection to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
} catch (error) {
|
||||
// Some connections might fail - that's expected
|
||||
console.log(` Connection ${i + 1} failed (expected during flooding)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Created ${connections.length} connections`);
|
||||
|
||||
// Close all connections
|
||||
connections.forEach(conn => {
|
||||
try {
|
||||
conn.destroy();
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 2: Test recovery
|
||||
console.log(' Waiting for server to recover...');
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
const socket2 = await createConnection();
|
||||
const recoverySuccess = await testBasicSmtpFlow(socket2);
|
||||
|
||||
expect(recoverySuccess).toEqual(true);
|
||||
console.log('✓ Server recovered from connection flooding');
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
connections.forEach(conn => conn.destroy());
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-04: Error recovery - Mixed error scenario', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
console.log('\nTesting recovery from mixed error scenarios...');
|
||||
|
||||
// Create multiple error conditions simultaneously
|
||||
const errorPromises = [];
|
||||
|
||||
// Invalid command connection
|
||||
errorPromises.push((async () => {
|
||||
const socket = await createConnection();
|
||||
await waitForResponse(socket, '220');
|
||||
socket.write('TOTALLY_WRONG\r\n');
|
||||
await waitForResponse(socket);
|
||||
socket.destroy();
|
||||
})());
|
||||
|
||||
// Malformed data connection
|
||||
errorPromises.push((async () => {
|
||||
const socket = await createConnection();
|
||||
await waitForResponse(socket, '220');
|
||||
socket.write('MAIL FROM:<<<invalid>>>\r\n');
|
||||
try {
|
||||
await waitForResponse(socket);
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
socket.destroy();
|
||||
})());
|
||||
|
||||
// Abrupt disconnection
|
||||
errorPromises.push((async () => {
|
||||
const socket = await createConnection();
|
||||
socket.destroy();
|
||||
})());
|
||||
|
||||
// Wait for all errors to execute
|
||||
await Promise.allSettled(errorPromises);
|
||||
|
||||
console.log(' All error scenarios executed');
|
||||
|
||||
// Test recovery
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
const socket = await createConnection();
|
||||
const recoverySuccess = await testBasicSmtpFlow(socket);
|
||||
|
||||
expect(recoverySuccess).toEqual(true);
|
||||
console.log('✓ Server recovered from mixed error scenarios');
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup server', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.start();
|
@ -0,0 +1,335 @@
|
||||
import * as plugins from '@git.zone/tstest/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
||||
|
||||
const TEST_PORT = 2525;
|
||||
|
||||
let testServer;
|
||||
|
||||
interface DnsTestResult {
|
||||
scenario: string;
|
||||
domain: string;
|
||||
expectedBehavior: string;
|
||||
mailFromSuccess: boolean;
|
||||
rcptToSuccess: boolean;
|
||||
mailFromResponse: string;
|
||||
rcptToResponse: string;
|
||||
handledGracefully: boolean;
|
||||
}
|
||||
|
||||
// Helper function to wait for SMTP response
|
||||
const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => {
|
||||
socket.removeListener('data', handler);
|
||||
reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`));
|
||||
}, timeout);
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\r\n');
|
||||
|
||||
// Check if we have a complete response
|
||||
for (const line of lines) {
|
||||
if (expectedCode) {
|
||||
if (line.startsWith(expectedCode + ' ')) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Any complete response line
|
||||
if (line.match(/^\d{3} /)) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', handler);
|
||||
});
|
||||
};
|
||||
|
||||
tap.test('prepare server', async () => {
|
||||
testServer = await startTestServer({ port: TEST_PORT });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
tap.test('REL-05: DNS resolution failure handling - Non-existent domains', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
// Read greeting
|
||||
await waitForResponse(socket, '220');
|
||||
|
||||
// Send EHLO
|
||||
socket.write('EHLO dns-test\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
console.log('Testing DNS resolution for non-existent domains...');
|
||||
|
||||
// Test 1: Non-existent domain in MAIL FROM
|
||||
socket.write('MAIL FROM:<sender@non-existent-domain-12345.invalid>\r\n');
|
||||
const mailResponse = await waitForResponse(socket);
|
||||
|
||||
console.log(' MAIL FROM response:', mailResponse.trim());
|
||||
|
||||
// Server should either accept (and defer later) or reject immediately
|
||||
const mailFromHandled = mailResponse.includes('250') ||
|
||||
mailResponse.includes('450') ||
|
||||
mailResponse.includes('550');
|
||||
expect(mailFromHandled).toEqual(true);
|
||||
|
||||
// Reset if needed
|
||||
if (mailResponse.includes('250')) {
|
||||
socket.write('RSET\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
}
|
||||
|
||||
// Test 2: Non-existent domain in RCPT TO
|
||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
||||
const mailFromResp = await waitForResponse(socket, '250');
|
||||
expect(mailFromResp).toInclude('250');
|
||||
|
||||
socket.write('RCPT TO:<recipient@non-existent-domain-xyz.invalid>\r\n');
|
||||
const rcptResponse = await waitForResponse(socket);
|
||||
|
||||
console.log(' RCPT TO response:', rcptResponse.trim());
|
||||
|
||||
// Server may accept (and defer validation) or reject immediately
|
||||
const rcptToHandled = rcptResponse.includes('250') || // Accepted (for later validation)
|
||||
rcptResponse.includes('450') || // Temporary failure
|
||||
rcptResponse.includes('550') || // Permanent failure
|
||||
rcptResponse.includes('553'); // Address error
|
||||
expect(rcptToHandled).toEqual(true);
|
||||
|
||||
socket.write('QUIT\r\n');
|
||||
socket.end();
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-05: DNS resolution failure handling - Malformed domains', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
// Read greeting
|
||||
await waitForResponse(socket, '220');
|
||||
|
||||
// Send EHLO
|
||||
socket.write('EHLO malformed-test\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
console.log('\nTesting malformed domain handling...');
|
||||
|
||||
const malformedDomains = [
|
||||
'malformed..domain..test',
|
||||
'invalid-.domain.com',
|
||||
'domain.with.spaces .com',
|
||||
'.leading-dot.com',
|
||||
'trailing-dot.com.',
|
||||
'domain@with@at.com',
|
||||
'a'.repeat(255) + '.toolong.com' // Domain too long
|
||||
];
|
||||
|
||||
for (const domain of malformedDomains) {
|
||||
console.log(` Testing: ${domain.substring(0, 50)}${domain.length > 50 ? '...' : ''}`);
|
||||
|
||||
socket.write(`MAIL FROM:<test@${domain}>\r\n`);
|
||||
const response = await waitForResponse(socket);
|
||||
|
||||
// Server should reject malformed domains or accept for later validation
|
||||
const properlyHandled = response.includes('250') || // Accepted (may validate later)
|
||||
response.includes('501') || // Syntax error
|
||||
response.includes('550') || // Rejected
|
||||
response.includes('553'); // Address error
|
||||
|
||||
console.log(` Response: ${response.trim().substring(0, 50)}`);
|
||||
expect(properlyHandled).toEqual(true);
|
||||
|
||||
// Reset if needed
|
||||
if (!response.includes('5')) {
|
||||
socket.write('RSET\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
}
|
||||
}
|
||||
|
||||
socket.write('QUIT\r\n');
|
||||
socket.end();
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-05: DNS resolution failure handling - Special cases', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
// Read greeting
|
||||
await waitForResponse(socket, '220');
|
||||
|
||||
// Send EHLO
|
||||
socket.write('EHLO special-test\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
console.log('\nTesting special DNS cases...');
|
||||
|
||||
// Test 1: Localhost (may be accepted or rejected)
|
||||
socket.write('MAIL FROM:<sender@localhost>\r\n');
|
||||
const localhostResponse = await waitForResponse(socket);
|
||||
|
||||
console.log(' Localhost response:', localhostResponse.trim());
|
||||
const localhostHandled = localhostResponse.includes('250') || localhostResponse.includes('501');
|
||||
expect(localhostHandled).toEqual(true);
|
||||
|
||||
// Only reset if transaction was started
|
||||
if (localhostResponse.includes('250')) {
|
||||
socket.write('RSET\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
}
|
||||
|
||||
// Test 2: IP address (should work)
|
||||
socket.write('MAIL FROM:<sender@[127.0.0.1]>\r\n');
|
||||
const ipResponse = await waitForResponse(socket);
|
||||
|
||||
console.log(' IP address response:', ipResponse.trim());
|
||||
const ipHandled = ipResponse.includes('250') || ipResponse.includes('501');
|
||||
expect(ipHandled).toEqual(true);
|
||||
|
||||
// Only reset if transaction was started
|
||||
if (ipResponse.includes('250')) {
|
||||
socket.write('RSET\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
}
|
||||
|
||||
// Test 3: Empty domain
|
||||
socket.write('MAIL FROM:<sender@>\r\n');
|
||||
const emptyResponse = await waitForResponse(socket);
|
||||
|
||||
console.log(' Empty domain response:', emptyResponse.trim());
|
||||
expect(emptyResponse).toMatch(/50[1-3]/); // Should reject
|
||||
|
||||
socket.write('QUIT\r\n');
|
||||
socket.end();
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-05: DNS resolution failure handling - Mixed valid/invalid recipients', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
// Read greeting
|
||||
await waitForResponse(socket, '220');
|
||||
|
||||
// Send EHLO
|
||||
socket.write('EHLO mixed-test\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
console.log('\nTesting mixed valid/invalid recipients...');
|
||||
|
||||
// Start transaction
|
||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
||||
const mailFromResp = await waitForResponse(socket, '250');
|
||||
expect(mailFromResp).toInclude('250');
|
||||
|
||||
// Add valid recipient
|
||||
socket.write('RCPT TO:<valid@example.com>\r\n');
|
||||
const validRcptResponse = await waitForResponse(socket, '250');
|
||||
|
||||
console.log(' Valid recipient:', validRcptResponse.trim());
|
||||
expect(validRcptResponse).toInclude('250');
|
||||
|
||||
// Add invalid recipient
|
||||
socket.write('RCPT TO:<invalid@non-existent-domain-abc.invalid>\r\n');
|
||||
const invalidRcptResponse = await waitForResponse(socket);
|
||||
|
||||
console.log(' Invalid recipient:', invalidRcptResponse.trim());
|
||||
|
||||
// Server may accept (for later validation) or reject invalid domain
|
||||
const invalidHandled = invalidRcptResponse.includes('250') || // Accepted (for later validation)
|
||||
invalidRcptResponse.includes('450') ||
|
||||
invalidRcptResponse.includes('550') ||
|
||||
invalidRcptResponse.includes('553');
|
||||
expect(invalidHandled).toEqual(true);
|
||||
|
||||
// Try to send data (should work if at least one valid recipient)
|
||||
socket.write('DATA\r\n');
|
||||
const dataResponse = await waitForResponse(socket);
|
||||
|
||||
if (dataResponse.includes('354')) {
|
||||
socket.write('Subject: Mixed recipient test\r\n\r\nTest\r\n.\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
console.log(' Message accepted with valid recipient');
|
||||
} else {
|
||||
console.log(' Server rejected DATA (acceptable behavior)');
|
||||
}
|
||||
|
||||
socket.write('QUIT\r\n');
|
||||
socket.end();
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup server', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.start();
|
@ -0,0 +1,410 @@
|
||||
import * as plugins from '@git.zone/tstest/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
||||
|
||||
const TEST_PORT = 2525;
|
||||
|
||||
let testServer;
|
||||
|
||||
const createConnection = async (): Promise<net.Socket> => {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
// Helper function to wait for SMTP response
|
||||
const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => {
|
||||
socket.removeListener('data', handler);
|
||||
reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`));
|
||||
}, timeout);
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\r\n');
|
||||
|
||||
// Check if we have a complete response
|
||||
for (const line of lines) {
|
||||
if (expectedCode) {
|
||||
if (line.startsWith(expectedCode + ' ')) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Any complete response line
|
||||
if (line.match(/^\d{3} /)) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', handler);
|
||||
});
|
||||
};
|
||||
|
||||
const getResponse = waitForResponse;
|
||||
|
||||
const testBasicSmtpFlow = async (socket: net.Socket): Promise<boolean> => {
|
||||
try {
|
||||
await waitForResponse(socket, '220');
|
||||
|
||||
socket.write('EHLO test.example.com\r\n');
|
||||
const ehloResp = await waitForResponse(socket, '250');
|
||||
if (!ehloResp.includes('250')) return false;
|
||||
|
||||
socket.write('MAIL FROM:<test@example.com>\r\n');
|
||||
const mailResp = await waitForResponse(socket, '250');
|
||||
if (!mailResp.includes('250')) return false;
|
||||
|
||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
||||
const rcptResp = await waitForResponse(socket, '250');
|
||||
if (!rcptResp.includes('250')) return false;
|
||||
|
||||
socket.write('DATA\r\n');
|
||||
const dataResp = await waitForResponse(socket, '354');
|
||||
if (!dataResp.includes('354')) return false;
|
||||
|
||||
const testEmail = [
|
||||
'From: test@example.com',
|
||||
'To: recipient@example.com',
|
||||
'Subject: Interruption Recovery Test',
|
||||
'',
|
||||
'This email tests server recovery after network interruption.',
|
||||
'.',
|
||||
''
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(testEmail);
|
||||
const finalResp = await waitForResponse(socket, '250');
|
||||
|
||||
socket.write('QUIT\r\n');
|
||||
socket.end();
|
||||
|
||||
return finalResp.includes('250');
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
tap.test('prepare server', async () => {
|
||||
testServer = await startTestServer({ port: TEST_PORT });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
tap.test('REL-06: Network interruption - Sudden connection drop', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
console.log('Testing sudden connection drop during session...');
|
||||
|
||||
// Phase 1: Create connection and drop it mid-session
|
||||
const socket1 = await createConnection();
|
||||
await waitForResponse(socket1, '220');
|
||||
|
||||
socket1.write('EHLO testhost\r\n');
|
||||
await waitForResponse(socket1, '250');
|
||||
|
||||
socket1.write('MAIL FROM:<sender@example.com>\r\n');
|
||||
await waitForResponse(socket1, '250');
|
||||
|
||||
// Abruptly close connection during active session
|
||||
socket1.destroy();
|
||||
console.log(' Connection abruptly closed');
|
||||
|
||||
// Phase 2: Test recovery
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const socket2 = await createConnection();
|
||||
const recoverySuccess = await testBasicSmtpFlow(socket2);
|
||||
|
||||
expect(recoverySuccess).toEqual(true);
|
||||
console.log('✓ Server recovered from sudden connection drop');
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-06: Network interruption - Data transfer interruption', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
console.log('\nTesting connection interruption during data transfer...');
|
||||
|
||||
const socket = await createConnection();
|
||||
await waitForResponse(socket, '220');
|
||||
|
||||
socket.write('EHLO datatest\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
socket.write('DATA\r\n');
|
||||
const dataResp = await waitForResponse(socket, '354');
|
||||
expect(dataResp).toInclude('354');
|
||||
|
||||
// Start sending data but interrupt midway
|
||||
socket.write('From: sender@example.com\r\n');
|
||||
socket.write('To: recipient@example.com\r\n');
|
||||
socket.write('Subject: Interruption Test\r\n\r\n');
|
||||
socket.write('This email will be interrupted...\r\n');
|
||||
|
||||
// Wait briefly then destroy connection (simulating network loss)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
socket.destroy();
|
||||
console.log(' Connection interrupted during data transfer');
|
||||
|
||||
// Test recovery
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
const newSocket = await createConnection();
|
||||
const recoverySuccess = await testBasicSmtpFlow(newSocket);
|
||||
|
||||
expect(recoverySuccess).toEqual(true);
|
||||
console.log('✓ Server recovered from data transfer interruption');
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-06: Network interruption - Rapid reconnection attempts', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const connections: net.Socket[] = [];
|
||||
|
||||
try {
|
||||
console.log('\nTesting rapid reconnection after interruptions...');
|
||||
|
||||
// Create and immediately destroy multiple connections
|
||||
console.log(' Creating 5 unstable connections...');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
const socket = net.createConnection({
|
||||
host: 'localhost',
|
||||
port: TEST_PORT,
|
||||
timeout: 2000
|
||||
});
|
||||
|
||||
connections.push(socket);
|
||||
|
||||
// Destroy after short random delay to simulate instability
|
||||
setTimeout(() => socket.destroy(), 50 + Math.random() * 150);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
} catch (error) {
|
||||
// Expected - some connections might fail
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Now test if server can handle normal connections
|
||||
let successfulConnections = 0;
|
||||
console.log(' Testing recovery with stable connections...');
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
const socket = await createConnection();
|
||||
const success = await testBasicSmtpFlow(socket);
|
||||
|
||||
if (success) {
|
||||
successfulConnections++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` Connection ${i + 1} failed:`, error.message);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
const recoveryRate = successfulConnections / 3;
|
||||
console.log(` Recovery rate: ${successfulConnections}/3 (${(recoveryRate * 100).toFixed(0)}%)`);
|
||||
|
||||
expect(recoveryRate).toBeGreaterThanOrEqual(0.66); // At least 2/3 should succeed
|
||||
console.log('✓ Server recovered from rapid reconnection attempts');
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
connections.forEach(conn => conn.destroy());
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-06: Network interruption - Partial command interruption', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
console.log('\nTesting partial command transmission interruption...');
|
||||
|
||||
const socket = await createConnection();
|
||||
await waitForResponse(socket, '220');
|
||||
|
||||
// Send partial EHLO command and interrupt
|
||||
socket.write('EH');
|
||||
console.log(' Sent partial command "EH"');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
socket.destroy();
|
||||
console.log(' Connection destroyed with incomplete command');
|
||||
|
||||
// Test recovery
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const newSocket = await createConnection();
|
||||
const recoverySuccess = await testBasicSmtpFlow(newSocket);
|
||||
|
||||
expect(recoverySuccess).toEqual(true);
|
||||
console.log('✓ Server recovered from partial command interruption');
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-06: Network interruption - Multiple interruption types', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const results: Array<{ type: string; recovered: boolean }> = [];
|
||||
|
||||
try {
|
||||
console.log('\nTesting recovery from multiple interruption types...');
|
||||
|
||||
// Test 1: Interrupt after greeting
|
||||
try {
|
||||
const socket = await createConnection();
|
||||
await waitForResponse(socket, '220');
|
||||
socket.destroy();
|
||||
results.push({ type: 'after-greeting', recovered: false });
|
||||
} catch (e) {
|
||||
results.push({ type: 'after-greeting', recovered: false });
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Test 2: Interrupt during EHLO
|
||||
try {
|
||||
const socket = await createConnection();
|
||||
await waitForResponse(socket, '220');
|
||||
socket.write('EHLO te');
|
||||
socket.destroy();
|
||||
results.push({ type: 'during-ehlo', recovered: false });
|
||||
} catch (e) {
|
||||
results.push({ type: 'during-ehlo', recovered: false });
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Test 3: Interrupt with invalid data
|
||||
try {
|
||||
const socket = await createConnection();
|
||||
await waitForResponse(socket, '220');
|
||||
socket.write('\x00\x01\x02\x03');
|
||||
socket.destroy();
|
||||
results.push({ type: 'invalid-data', recovered: false });
|
||||
} catch (e) {
|
||||
results.push({ type: 'invalid-data', recovered: false });
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Test final recovery
|
||||
try {
|
||||
const socket = await createConnection();
|
||||
const success = await testBasicSmtpFlow(socket);
|
||||
|
||||
if (success) {
|
||||
// Mark all previous tests as recovered
|
||||
results.forEach(r => r.recovered = true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Final recovery failed:', error.message);
|
||||
}
|
||||
|
||||
const recoveredCount = results.filter(r => r.recovered).length;
|
||||
console.log(`\nInterruption recovery summary:`);
|
||||
results.forEach(r => {
|
||||
console.log(` ${r.type}: ${r.recovered ? 'recovered' : 'failed'}`);
|
||||
});
|
||||
|
||||
expect(recoveredCount).toBeGreaterThan(0);
|
||||
console.log(`✓ Server recovered from ${recoveredCount}/${results.length} interruption scenarios`);
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('REL-06: Network interruption - Long delay recovery', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
try {
|
||||
console.log('\nTesting recovery after long network interruption...');
|
||||
|
||||
// Create connection and start transaction
|
||||
const socket = await createConnection();
|
||||
await waitForResponse(socket, '220');
|
||||
|
||||
socket.write('EHLO longdelay\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
||||
await waitForResponse(socket, '250');
|
||||
|
||||
// Simulate long network interruption
|
||||
socket.pause();
|
||||
console.log(' Connection paused (simulating network freeze)');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000)); // 5 second "freeze"
|
||||
|
||||
// Try to continue - should fail
|
||||
socket.resume();
|
||||
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
||||
|
||||
let continuationFailed = false;
|
||||
try {
|
||||
await waitForResponse(socket, '250', 3000);
|
||||
} catch (error) {
|
||||
continuationFailed = true;
|
||||
console.log(' Continuation failed as expected');
|
||||
}
|
||||
|
||||
socket.destroy();
|
||||
|
||||
// Test recovery with new connection
|
||||
const newSocket = await createConnection();
|
||||
const recoverySuccess = await testBasicSmtpFlow(newSocket);
|
||||
|
||||
expect(recoverySuccess).toEqual(true);
|
||||
console.log('✓ Server recovered after long network interruption');
|
||||
done.resolve();
|
||||
} catch (error) {
|
||||
done.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup server', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.start();
|
Reference in New Issue
Block a user