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-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((resolve, reject) => { socket1.once('connect', resolve); socket1.once('error', reject); }); // Read greeting const greeting1 = await new Promise((resolve) => { socket1.once('data', (chunk) => { resolve(chunk.toString()); }); }); expect(greeting1).toInclude('220'); console.log('Initial connection successful'); // Send EHLO socket1.write('EHLO testhost\r\n'); await new Promise((resolve) => { let data = ''; const handleData = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('250 ') && !data.includes('250-')) { socket1.removeListener('data', handleData); resolve(); } }; socket1.on('data', handleData); }); // Complete a transaction socket1.write('MAIL FROM:\r\n'); await new Promise((resolve) => { socket1.once('data', (chunk) => { const response = chunk.toString(); expect(response).toInclude('250'); resolve(); }); }); socket1.write('RCPT TO:\r\n'); await new Promise((resolve) => { socket1.once('data', (chunk) => { const response = chunk.toString(); expect(response).toInclude('250'); resolve(); }); }); socket1.write('DATA\r\n'); await new Promise((resolve) => { socket1.once('data', (chunk) => { const response = chunk.toString(); expect(response).toInclude('354'); resolve(); }); }); 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); await new Promise((resolve) => { socket1.once('data', (chunk) => { const response = chunk.toString(); expect(response).toInclude('250'); resolve(); }); }); socket1.write('QUIT\r\n'); 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((resolve, reject) => { socket2.once('connect', resolve); socket2.once('error', reject); }); // Read greeting after "restart" const greeting2 = await new Promise((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 new Promise((resolve) => { let data = ''; const handleData = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('250 ') && !data.includes('250-')) { socket2.removeListener('data', handleData); resolve(); } }; socket2.on('data', handleData); }); // Complete another transaction to verify full recovery socket2.write('MAIL FROM:\r\n'); await new Promise((resolve) => { socket2.once('data', (chunk) => { const response = chunk.toString(); expect(response).toInclude('250'); resolve(); }); }); socket2.write('RCPT TO:\r\n'); await new Promise((resolve) => { socket2.once('data', (chunk) => { const response = chunk.toString(); expect(response).toInclude('250'); resolve(); }); }); socket2.write('DATA\r\n'); await new Promise((resolve) => { socket2.once('data', (chunk) => { const response = chunk.toString(); expect(response).toInclude('354'); resolve(); }); }); 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); await new Promise((resolve) => { socket2.once('data', (chunk) => { const response = chunk.toString(); expect(response).toInclude('250'); resolve(); }); }); socket2.write('QUIT\r\n'); 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((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 const greeting = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Greeting timeout')); }, 3000); socket.once('data', (chunk) => { clearTimeout(timeout); resolve(chunk.toString()); }); }); if (greeting.includes('220')) { successfulReconnects++; socket.write('QUIT\r\n'); socket.end(); } else { socket.destroy(); } // 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((resolve, reject) => { socket1.once('connect', resolve); socket1.once('error', reject); }); // Read greeting await new Promise((resolve) => { socket1.once('data', () => resolve()); }); // Send EHLO socket1.write('EHLO persistence-test\r\n'); await new Promise((resolve) => { let data = ''; const handleData = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('250 ') && !data.includes('250-')) { socket1.removeListener('data', handleData); resolve(); } }; socket1.on('data', handleData); }); // Start transaction but don't complete it socket1.write('MAIL FROM:\r\n'); await new Promise((resolve) => { socket1.once('data', (chunk) => { const response = chunk.toString(); expect(response).toInclude('250'); resolve(); }); }); // 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((resolve, reject) => { socket2.once('connect', resolve); socket2.once('error', reject); }); // Read greeting await new Promise((resolve) => { socket2.once('data', () => resolve()); }); // Send EHLO socket2.write('EHLO recovery-test\r\n'); await new Promise((resolve) => { let data = ''; const handleData = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('250 ') && !data.includes('250-')) { socket2.removeListener('data', handleData); resolve(); } }; socket2.on('data', handleData); }); // Try new transaction - should work without issues from previous incomplete one socket2.write('MAIL FROM:\r\n'); const mailResponse = await new Promise((resolve) => { socket2.once('data', (chunk) => { resolve(chunk.toString()); }); }); expect(mailResponse).toInclude('250'); console.log('Server recovered successfully - new transaction started without issues'); socket2.write('QUIT\r\n'); socket2.end(); done.resolve(); } catch (error) { done.reject(error); } }); tap.test('cleanup server', async () => { await stopTestServer(testServer); }); tap.start();