dcrouter/test/suite/smtpserver_reliability/test.rel-04.error-recovery.ts
2025-05-25 19:05:43 +00:00

401 lines
12 KiB
TypeScript

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);
});
export default tap.start();