2025-05-23 19:09:30 +00:00
|
|
|
import * as plugins from '@git.zone/tstest/tapbundle';
|
|
|
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
2025-05-23 19:03:44 +00:00
|
|
|
import * as net from 'net';
|
|
|
|
import { startTestServer, stopTestServer } from '../server.loader.js';
|
|
|
|
|
|
|
|
const TEST_PORT = 2525;
|
|
|
|
|
|
|
|
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;
|
|
|
|
};
|
|
|
|
|
|
|
|
const getResponse = (socket: net.Socket, commandName: string): Promise<string> => {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
reject(new Error(`${commandName} response timeout`));
|
|
|
|
}, 3000);
|
|
|
|
|
|
|
|
socket.once('data', (chunk: Buffer) => {
|
|
|
|
clearTimeout(timeout);
|
|
|
|
resolve(chunk.toString());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
const testBasicSmtpFlow = async (socket: net.Socket): Promise<boolean> => {
|
|
|
|
try {
|
|
|
|
await getResponse(socket, 'GREETING');
|
|
|
|
|
|
|
|
socket.write('EHLO test.example.com\r\n');
|
|
|
|
const ehloResp = await getResponse(socket, 'EHLO');
|
|
|
|
if (!ehloResp.includes('250')) return false;
|
|
|
|
|
|
|
|
// Wait for complete EHLO response
|
|
|
|
if (ehloResp.includes('250-')) {
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
}
|
|
|
|
|
|
|
|
socket.write('MAIL FROM:<test@example.com>\r\n');
|
|
|
|
const mailResp = await getResponse(socket, 'MAIL FROM');
|
|
|
|
if (!mailResp.includes('250')) return false;
|
|
|
|
|
|
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
|
|
const rcptResp = await getResponse(socket, 'RCPT TO');
|
|
|
|
if (!rcptResp.includes('250')) return false;
|
|
|
|
|
|
|
|
socket.write('DATA\r\n');
|
|
|
|
const dataResp = await getResponse(socket, 'DATA');
|
|
|
|
if (!dataResp.includes('354')) return false;
|
|
|
|
|
|
|
|
const testEmail = [
|
|
|
|
'From: test@example.com',
|
|
|
|
'To: recipient@example.com',
|
|
|
|
'Subject: Interruption Recovery Test',
|
|
|
|
'',
|
|
|
|
'This email tests server recovery after network interruption.',
|
|
|
|
'.',
|
|
|
|
''
|
|
|
|
].join('\r\n');
|
|
|
|
|
|
|
|
socket.write(testEmail);
|
|
|
|
const finalResp = await getResponse(socket, 'EMAIL DATA');
|
|
|
|
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
socket.end();
|
|
|
|
|
|
|
|
return finalResp.includes('250');
|
|
|
|
} catch (error) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
tap.test('prepare server', async () => {
|
|
|
|
await startTestServer();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('REL-06: Network interruption - Sudden connection drop', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
try {
|
|
|
|
console.log('Testing sudden connection drop during session...');
|
|
|
|
|
|
|
|
// Phase 1: Create connection and drop it mid-session
|
|
|
|
const socket1 = await createConnection();
|
|
|
|
await getResponse(socket1, 'GREETING');
|
|
|
|
|
|
|
|
socket1.write('EHLO testhost\r\n');
|
|
|
|
let data = '';
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
const handleData = (chunk: Buffer) => {
|
|
|
|
data += chunk.toString();
|
|
|
|
if (data.includes('250 ') && !data.includes('250-')) {
|
|
|
|
socket1.removeListener('data', handleData);
|
|
|
|
resolve();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
socket1.on('data', handleData);
|
|
|
|
});
|
|
|
|
|
|
|
|
socket1.write('MAIL FROM:<sender@example.com>\r\n');
|
|
|
|
await getResponse(socket1, 'MAIL FROM');
|
|
|
|
|
|
|
|
// Abruptly close connection during active session
|
|
|
|
socket1.destroy();
|
|
|
|
console.log(' Connection abruptly closed');
|
|
|
|
|
|
|
|
// Phase 2: Test recovery
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
|
|
|
|
const socket2 = await createConnection();
|
|
|
|
const recoverySuccess = await testBasicSmtpFlow(socket2);
|
|
|
|
|
|
|
|
expect(recoverySuccess).toBeTrue();
|
|
|
|
console.log('✓ Server recovered from sudden connection drop');
|
|
|
|
done.resolve();
|
|
|
|
} catch (error) {
|
|
|
|
done.reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('REL-06: Network interruption - Data transfer interruption', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
try {
|
|
|
|
console.log('\nTesting connection interruption during data transfer...');
|
|
|
|
|
|
|
|
const socket = await createConnection();
|
|
|
|
await getResponse(socket, 'GREETING');
|
|
|
|
|
|
|
|
socket.write('EHLO datatest\r\n');
|
|
|
|
let data = '';
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
const handleData = (chunk: Buffer) => {
|
|
|
|
data += chunk.toString();
|
|
|
|
if (data.includes('250 ') && !data.includes('250-')) {
|
|
|
|
socket.removeListener('data', handleData);
|
|
|
|
resolve();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
socket.on('data', handleData);
|
|
|
|
});
|
|
|
|
|
|
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
|
|
await getResponse(socket, 'MAIL FROM');
|
|
|
|
|
|
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
|
|
await getResponse(socket, 'RCPT TO');
|
|
|
|
|
|
|
|
socket.write('DATA\r\n');
|
|
|
|
const dataResp = await getResponse(socket, 'DATA');
|
|
|
|
expect(dataResp).toInclude('354');
|
|
|
|
|
|
|
|
// Start sending data but interrupt midway
|
|
|
|
socket.write('From: sender@example.com\r\n');
|
|
|
|
socket.write('To: recipient@example.com\r\n');
|
|
|
|
socket.write('Subject: Interruption Test\r\n\r\n');
|
|
|
|
socket.write('This email will be interrupted...\r\n');
|
|
|
|
|
|
|
|
// Wait briefly then destroy connection (simulating network loss)
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
socket.destroy();
|
|
|
|
console.log(' Connection interrupted during data transfer');
|
|
|
|
|
|
|
|
// Test recovery
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
|
|
|
|
|
|
const newSocket = await createConnection();
|
|
|
|
const recoverySuccess = await testBasicSmtpFlow(newSocket);
|
|
|
|
|
|
|
|
expect(recoverySuccess).toBeTrue();
|
|
|
|
console.log('✓ Server recovered from data transfer interruption');
|
|
|
|
done.resolve();
|
|
|
|
} catch (error) {
|
|
|
|
done.reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('REL-06: Network interruption - Rapid reconnection attempts', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
const connections: net.Socket[] = [];
|
|
|
|
|
|
|
|
try {
|
|
|
|
console.log('\nTesting rapid reconnection after interruptions...');
|
|
|
|
|
|
|
|
// Create and immediately destroy multiple connections
|
|
|
|
console.log(' Creating 5 unstable connections...');
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
try {
|
|
|
|
const socket = net.createConnection({
|
|
|
|
host: 'localhost',
|
|
|
|
port: TEST_PORT,
|
|
|
|
timeout: 2000
|
|
|
|
});
|
|
|
|
|
|
|
|
connections.push(socket);
|
|
|
|
|
|
|
|
// Destroy after short random delay to simulate instability
|
|
|
|
setTimeout(() => socket.destroy(), 50 + Math.random() * 150);
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
} catch (error) {
|
|
|
|
// Expected - some connections might fail
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Wait for cleanup
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
|
|
|
|
// Now test if server can handle normal connections
|
|
|
|
let successfulConnections = 0;
|
|
|
|
console.log(' Testing recovery with stable connections...');
|
|
|
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
try {
|
|
|
|
const socket = await createConnection();
|
|
|
|
const success = await testBasicSmtpFlow(socket);
|
|
|
|
|
|
|
|
if (success) {
|
|
|
|
successfulConnections++;
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.log(` Connection ${i + 1} failed:`, error.message);
|
|
|
|
}
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
}
|
|
|
|
|
|
|
|
const recoveryRate = successfulConnections / 3;
|
|
|
|
console.log(` Recovery rate: ${successfulConnections}/3 (${(recoveryRate * 100).toFixed(0)}%)`);
|
|
|
|
|
|
|
|
expect(recoveryRate).toBeGreaterThanOrEqual(0.66); // At least 2/3 should succeed
|
|
|
|
console.log('✓ Server recovered from rapid reconnection attempts');
|
|
|
|
done.resolve();
|
|
|
|
} catch (error) {
|
|
|
|
connections.forEach(conn => conn.destroy());
|
|
|
|
done.reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('REL-06: Network interruption - Partial command interruption', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
try {
|
|
|
|
console.log('\nTesting partial command transmission interruption...');
|
|
|
|
|
|
|
|
const socket = await createConnection();
|
|
|
|
await getResponse(socket, 'GREETING');
|
|
|
|
|
|
|
|
// Send partial EHLO command and interrupt
|
|
|
|
socket.write('EH');
|
|
|
|
console.log(' Sent partial command "EH"');
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
socket.destroy();
|
|
|
|
console.log(' Connection destroyed with incomplete command');
|
|
|
|
|
|
|
|
// Test recovery
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
|
|
|
|
const newSocket = await createConnection();
|
|
|
|
const recoverySuccess = await testBasicSmtpFlow(newSocket);
|
|
|
|
|
|
|
|
expect(recoverySuccess).toBeTrue();
|
|
|
|
console.log('✓ Server recovered from partial command interruption');
|
|
|
|
done.resolve();
|
|
|
|
} catch (error) {
|
|
|
|
done.reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('REL-06: Network interruption - Multiple interruption types', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
const results: Array<{ type: string; recovered: boolean }> = [];
|
|
|
|
|
|
|
|
try {
|
|
|
|
console.log('\nTesting recovery from multiple interruption types...');
|
|
|
|
|
|
|
|
// Test 1: Interrupt after greeting
|
|
|
|
try {
|
|
|
|
const socket = await createConnection();
|
|
|
|
await getResponse(socket, 'GREETING');
|
|
|
|
socket.destroy();
|
|
|
|
results.push({ type: 'after-greeting', recovered: false });
|
|
|
|
} catch (e) {
|
|
|
|
results.push({ type: 'after-greeting', recovered: false });
|
|
|
|
}
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
|
|
|
|
// Test 2: Interrupt during EHLO
|
|
|
|
try {
|
|
|
|
const socket = await createConnection();
|
|
|
|
await getResponse(socket, 'GREETING');
|
|
|
|
socket.write('EHLO te');
|
|
|
|
socket.destroy();
|
|
|
|
results.push({ type: 'during-ehlo', recovered: false });
|
|
|
|
} catch (e) {
|
|
|
|
results.push({ type: 'during-ehlo', recovered: false });
|
|
|
|
}
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
|
|
|
|
// Test 3: Interrupt with invalid data
|
|
|
|
try {
|
|
|
|
const socket = await createConnection();
|
|
|
|
await getResponse(socket, 'GREETING');
|
|
|
|
socket.write('\x00\x01\x02\x03');
|
|
|
|
socket.destroy();
|
|
|
|
results.push({ type: 'invalid-data', recovered: false });
|
|
|
|
} catch (e) {
|
|
|
|
results.push({ type: 'invalid-data', recovered: false });
|
|
|
|
}
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
|
|
|
|
// Test final recovery
|
|
|
|
try {
|
|
|
|
const socket = await createConnection();
|
|
|
|
const success = await testBasicSmtpFlow(socket);
|
|
|
|
|
|
|
|
if (success) {
|
|
|
|
// Mark all previous tests as recovered
|
|
|
|
results.forEach(r => r.recovered = true);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.log('Final recovery failed:', error.message);
|
|
|
|
}
|
|
|
|
|
|
|
|
const recoveredCount = results.filter(r => r.recovered).length;
|
|
|
|
console.log(`\nInterruption recovery summary:`);
|
|
|
|
results.forEach(r => {
|
|
|
|
console.log(` ${r.type}: ${r.recovered ? 'recovered' : 'failed'}`);
|
|
|
|
});
|
|
|
|
|
|
|
|
expect(recoveredCount).toBeGreaterThan(0);
|
|
|
|
console.log(`✓ Server recovered from ${recoveredCount}/${results.length} interruption scenarios`);
|
|
|
|
done.resolve();
|
|
|
|
} catch (error) {
|
|
|
|
done.reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('REL-06: Network interruption - Long delay recovery', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
try {
|
|
|
|
console.log('\nTesting recovery after long network interruption...');
|
|
|
|
|
|
|
|
// Create connection and start transaction
|
|
|
|
const socket = await createConnection();
|
|
|
|
await getResponse(socket, 'GREETING');
|
|
|
|
|
|
|
|
socket.write('EHLO longdelay\r\n');
|
|
|
|
let data = '';
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
const handleData = (chunk: Buffer) => {
|
|
|
|
data += chunk.toString();
|
|
|
|
if (data.includes('250 ') && !data.includes('250-')) {
|
|
|
|
socket.removeListener('data', handleData);
|
|
|
|
resolve();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
socket.on('data', handleData);
|
|
|
|
});
|
|
|
|
|
|
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
|
|
await getResponse(socket, 'MAIL FROM');
|
|
|
|
|
|
|
|
// Simulate long network interruption
|
|
|
|
socket.pause();
|
|
|
|
console.log(' Connection paused (simulating network freeze)');
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 5000)); // 5 second "freeze"
|
|
|
|
|
|
|
|
// Try to continue - should fail
|
|
|
|
socket.resume();
|
|
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
|
|
|
|
|
|
let continuationFailed = false;
|
|
|
|
try {
|
|
|
|
await getResponse(socket, 'RCPT TO');
|
|
|
|
} catch (error) {
|
|
|
|
continuationFailed = true;
|
|
|
|
console.log(' Continuation failed as expected');
|
|
|
|
}
|
|
|
|
|
|
|
|
socket.destroy();
|
|
|
|
|
|
|
|
// Test recovery with new connection
|
|
|
|
const newSocket = await createConnection();
|
|
|
|
const recoverySuccess = await testBasicSmtpFlow(newSocket);
|
|
|
|
|
|
|
|
expect(recoverySuccess).toBeTrue();
|
|
|
|
console.log('✓ Server recovered after long network interruption');
|
|
|
|
done.resolve();
|
|
|
|
} catch (error) {
|
|
|
|
done.reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('cleanup server', async () => {
|
|
|
|
await stopTestServer();
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.start();
|