feat(storage): add comprehensive tests for StorageManager with memory, filesystem, and custom function backends
feat(email): implement EmailSendJob class for robust email delivery with retry logic and MX record resolution feat(mail): restructure mail module exports for simplified access to core and delivery functionalities
This commit is contained in:
		
							
								
								
									
										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.ts';
 | 
			
		||||
 | 
			
		||||
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();
 | 
			
		||||
		Reference in New Issue
	
	Block a user