328 lines
9.5 KiB
TypeScript
328 lines
9.5 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;
|
|
|
|
// 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);
|
|
});
|
|
|
|
export default tap.start(); |