import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; import type { SmtpServer } from '../../../ts/mail/delivery/smtpserver/index.js'; const TEST_PORT = 2525; const TEST_TIMEOUT = 30000; let testServer: SmtpServer; tap.test('setup - start SMTP server for abrupt disconnection tests', async () => { testServer = await startTestServer(); await new Promise(resolve => setTimeout(resolve, 1000)); }); tap.test('Abrupt Disconnection - should handle socket destruction without QUIT', async (tools) => { const done = tools.defer(); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner const banner = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(banner).toInclude('220'); // Send EHLO socket.write('EHLO testhost\r\n'); await new Promise((resolve) => { let data = ''; const handler = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { socket.removeListener('data', handler); resolve(data); } }; socket.on('data', handler); }); // Abruptly disconnect without QUIT console.log('Destroying socket without QUIT...'); socket.destroy(); // Wait a moment for server to handle the disconnection await new Promise(resolve => setTimeout(resolve, 1000)); // Test server recovery - try new connection console.log('Testing server recovery with new connection...'); const recoverySocket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); const recoveryConnected = await new Promise((resolve) => { recoverySocket.once('connect', () => resolve(true)); recoverySocket.once('error', () => resolve(false)); setTimeout(() => resolve(false), 5000); }); expect(recoveryConnected).toEqual(true); if (recoveryConnected) { // Get banner from recovery connection const recoveryBanner = await new Promise((resolve) => { recoverySocket.once('data', (chunk) => resolve(chunk.toString())); }); expect(recoveryBanner).toInclude('220'); console.log('Server recovered successfully, accepting new connections'); // Clean up recovery connection properly recoverySocket.write('QUIT\r\n'); recoverySocket.end(); } } finally { done.resolve(); } }); tap.test('Abrupt Disconnection - should handle multiple simultaneous abrupt disconnections', async (tools) => { const done = tools.defer(); try { const connections = 5; const sockets: net.Socket[] = []; // Create multiple connections for (let i = 0; i < connections; i++) { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner await new Promise((resolve) => { socket.once('data', () => resolve()); }); sockets.push(socket); } console.log(`Created ${connections} connections`); // Abruptly disconnect all at once console.log('Destroying all sockets simultaneously...'); sockets.forEach(socket => socket.destroy()); // Wait for server to handle disconnections await new Promise(resolve => setTimeout(resolve, 2000)); // Test that server still accepts new connections console.log('Testing server stability after multiple abrupt disconnections...'); const testSocket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); const stillAccepting = await new Promise((resolve) => { testSocket.once('connect', () => resolve(true)); testSocket.once('error', () => resolve(false)); setTimeout(() => resolve(false), 5000); }); expect(stillAccepting).toEqual(true); if (stillAccepting) { const banner = await new Promise((resolve) => { testSocket.once('data', (chunk) => resolve(chunk.toString())); }); expect(banner).toInclude('220'); console.log('Server remained stable after multiple abrupt disconnections'); testSocket.write('QUIT\r\n'); testSocket.end(); } } finally { done.resolve(); } }); tap.test('Abrupt Disconnection - should handle disconnection during DATA transfer', async (tools) => { const done = tools.defer(); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO socket.write('EHLO testhost\r\n'); await new Promise((resolve) => { let data = ''; const handler = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { socket.removeListener('data', handler); resolve(data); } }; socket.on('data', handler); }); // Send MAIL FROM socket.write('MAIL FROM:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send RCPT TO socket.write('RCPT TO:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Start DATA socket.write('DATA\r\n'); const dataResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(dataResponse).toInclude('354'); // Send partial email data then disconnect abruptly socket.write('From: sender@example.com\r\n'); socket.write('To: recipient@example.com\r\n'); socket.write('Subject: Test '); console.log('Disconnecting during DATA transfer...'); socket.destroy(); // Wait for server to handle disconnection await new Promise(resolve => setTimeout(resolve, 1500)); // Verify server can handle new connections const newSocket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); const canConnect = await new Promise((resolve) => { newSocket.once('connect', () => resolve(true)); newSocket.once('error', () => resolve(false)); setTimeout(() => resolve(false), 5000); }); expect(canConnect).toEqual(true); if (canConnect) { const banner = await new Promise((resolve) => { newSocket.once('data', (chunk) => resolve(chunk.toString())); }); expect(banner).toInclude('220'); console.log('Server recovered from disconnection during DATA transfer'); newSocket.write('QUIT\r\n'); newSocket.end(); } } finally { done.resolve(); } }); tap.test('Abrupt Disconnection - should timeout idle connections', async (tools) => { const done = tools.defer(); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner const banner = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(banner).toInclude('220'); console.log('Connected, now testing idle timeout...'); // Don't send any commands and wait for server to potentially timeout // Most servers have a timeout of 5-10 minutes, so we'll test shorter let disconnectedByServer = false; socket.on('close', () => { disconnectedByServer = true; }); socket.on('end', () => { disconnectedByServer = true; }); // Wait 10 seconds to see if server has a short idle timeout await new Promise(resolve => setTimeout(resolve, 10000)); if (!disconnectedByServer) { console.log('Server maintains idle connections (no short timeout detected)'); // Send QUIT to close gracefully socket.write('QUIT\r\n'); socket.end(); } else { console.log('Server disconnected idle connection'); } // Either behavior is acceptable expect(true).toEqual(true); } finally { done.resolve(); } }); tap.test('cleanup - stop SMTP server', async () => { await stopTestServer(); expect(true).toEqual(true); }); tap.start();