This commit is contained in:
2025-05-24 13:37:19 +00:00
parent 35712b18bc
commit dc5c0b2584
19 changed files with 3536 additions and 3347 deletions

View File

@@ -22,42 +22,64 @@ const createConnection = async (): Promise<net.Socket> => {
return socket;
};
const getResponse = (socket: net.Socket, commandName: string): Promise<string> => {
// Helper function to wait for SMTP response
const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): 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());
});
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 {
await getResponse(socket, 'GREETING');
await waitForResponse(socket, '220');
socket.write('EHLO test.example.com\r\n');
const ehloResp = await getResponse(socket, 'EHLO');
const ehloResp = await waitForResponse(socket, '250');
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');
const mailResp = await waitForResponse(socket, '250');
if (!mailResp.includes('250')) return false;
socket.write('RCPT TO:<recipient@example.com>\r\n');
const rcptResp = await getResponse(socket, 'RCPT TO');
const rcptResp = await waitForResponse(socket, '250');
if (!rcptResp.includes('250')) return false;
socket.write('DATA\r\n');
const dataResp = await getResponse(socket, 'DATA');
const dataResp = await waitForResponse(socket, '354');
if (!dataResp.includes('354')) return false;
const testEmail = [
@@ -71,7 +93,7 @@ const testBasicSmtpFlow = async (socket: net.Socket): Promise<boolean> => {
].join('\r\n');
socket.write(testEmail);
const finalResp = await getResponse(socket, 'EMAIL DATA');
const finalResp = await waitForResponse(socket, '250');
socket.write('QUIT\r\n');
socket.end();
@@ -95,23 +117,13 @@ tap.test('REL-06: Network interruption - Sudden connection drop', async (tools)
// Phase 1: Create connection and drop it mid-session
const socket1 = await createConnection();
await getResponse(socket1, 'GREETING');
await waitForResponse(socket1, '220');
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);
});
await waitForResponse(socket1, '250');
socket1.write('MAIL FROM:<sender@example.com>\r\n');
await getResponse(socket1, 'MAIL FROM');
await waitForResponse(socket1, '250');
// Abruptly close connection during active session
socket1.destroy();
@@ -138,29 +150,19 @@ tap.test('REL-06: Network interruption - Data transfer interruption', async (too
console.log('\nTesting connection interruption during data transfer...');
const socket = await createConnection();
await getResponse(socket, 'GREETING');
await waitForResponse(socket, '220');
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);
});
await waitForResponse(socket, '250');
socket.write('MAIL FROM:<sender@example.com>\r\n');
await getResponse(socket, 'MAIL FROM');
await waitForResponse(socket, '250');
socket.write('RCPT TO:<recipient@example.com>\r\n');
await getResponse(socket, 'RCPT TO');
await waitForResponse(socket, '250');
socket.write('DATA\r\n');
const dataResp = await getResponse(socket, 'DATA');
const dataResp = await waitForResponse(socket, '354');
expect(dataResp).toInclude('354');
// Start sending data but interrupt midway
@@ -257,7 +259,7 @@ tap.test('REL-06: Network interruption - Partial command interruption', async (t
console.log('\nTesting partial command transmission interruption...');
const socket = await createConnection();
await getResponse(socket, 'GREETING');
await waitForResponse(socket, '220');
// Send partial EHLO command and interrupt
socket.write('EH');
@@ -291,7 +293,7 @@ tap.test('REL-06: Network interruption - Multiple interruption types', async (to
// Test 1: Interrupt after greeting
try {
const socket = await createConnection();
await getResponse(socket, 'GREETING');
await waitForResponse(socket, '220');
socket.destroy();
results.push({ type: 'after-greeting', recovered: false });
} catch (e) {
@@ -303,7 +305,7 @@ tap.test('REL-06: Network interruption - Multiple interruption types', async (to
// Test 2: Interrupt during EHLO
try {
const socket = await createConnection();
await getResponse(socket, 'GREETING');
await waitForResponse(socket, '220');
socket.write('EHLO te');
socket.destroy();
results.push({ type: 'during-ehlo', recovered: false });
@@ -316,7 +318,7 @@ tap.test('REL-06: Network interruption - Multiple interruption types', async (to
// Test 3: Interrupt with invalid data
try {
const socket = await createConnection();
await getResponse(socket, 'GREETING');
await waitForResponse(socket, '220');
socket.write('\x00\x01\x02\x03');
socket.destroy();
results.push({ type: 'invalid-data', recovered: false });
@@ -361,23 +363,13 @@ tap.test('REL-06: Network interruption - Long delay recovery', async (tools) =>
// Create connection and start transaction
const socket = await createConnection();
await getResponse(socket, 'GREETING');
await waitForResponse(socket, '220');
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);
});
await waitForResponse(socket, '250');
socket.write('MAIL FROM:<sender@example.com>\r\n');
await getResponse(socket, 'MAIL FROM');
await waitForResponse(socket, '250');
// Simulate long network interruption
socket.pause();
@@ -391,7 +383,7 @@ tap.test('REL-06: Network interruption - Long delay recovery', async (tools) =>
let continuationFailed = false;
try {
await getResponse(socket, 'RCPT TO');
await waitForResponse(socket, '250', 3000);
} catch (error) {
continuationFailed = true;
console.log(' Continuation failed as expected');

View File

@@ -58,6 +58,44 @@ const captureResourceMetrics = async (): Promise<ResourceMetrics> => {
};
};
// 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 analyzeResourceLeaks = (initial: ResourceMetrics, samples: Array<{ operation: number; metrics: ResourceMetrics }>, final: ResourceMetrics): LeakAnalysis => {
const memoryGrowthMB = final.memoryUsage.heapUsed - initial.memoryUsage.heapUsed;
@@ -123,55 +161,24 @@ tap.test('REL-03: Resource leak detection - Memory leak analysis', async (tools)
});
// Read greeting
await new Promise<void>((resolve) => {
socket.once('data', () => resolve());
});
await waitForResponse(socket, '220');
// Send EHLO
socket.write(`EHLO leaktest-${i}\r\n`);
await new Promise<void>((resolve) => {
let data = '';
const handleData = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('250 ') && !data.includes('250-')) {
socket.removeListener('data', handleData);
resolve();
}
};
socket.on('data', handleData);
});
await waitForResponse(socket, '250');
// Complete email transaction
socket.write(`MAIL FROM:<sender${i}@example.com>\r\n`);
await new Promise<void>((resolve) => {
socket.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('250');
resolve();
});
});
const mailResp = await waitForResponse(socket, '250');
expect(mailResp).toInclude('250');
socket.write(`RCPT TO:<recipient${i}@example.com>\r\n`);
await new Promise<void>((resolve) => {
socket.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('250');
resolve();
});
});
const rcptResp = await waitForResponse(socket, '250');
expect(rcptResp).toInclude('250');
socket.write('DATA\r\n');
await new Promise<void>((resolve) => {
socket.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('354');
resolve();
});
});
const dataResp = await waitForResponse(socket, '354');
expect(dataResp).toInclude('354');
const emailContent = [
`From: sender${i}@example.com`,
@@ -185,22 +192,12 @@ tap.test('REL-03: Resource leak detection - Memory leak analysis', async (tools)
].join('\r\n');
socket.write(emailContent);
await new Promise<void>((resolve) => {
socket.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('250');
resolve();
});
});
const sendResp = await waitForResponse(socket, '250');
expect(sendResp).toInclude('250');
socket.write('QUIT\r\n');
await new Promise<void>((resolve) => {
socket.once('data', () => {
socket.end();
resolve();
});
});
await waitForResponse(socket, '221');
socket.end();
// Capture metrics every 5 operations
if ((i + 1) % 5 === 0) {

View File

@@ -7,6 +7,44 @@ 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));
@@ -31,60 +69,26 @@ tap.test('REL-02: Restart recovery - Server state after restart', async (tools)
});
// Read greeting
const greeting1 = await new Promise<string>((resolve) => {
socket1.once('data', (chunk) => {
resolve(chunk.toString());
});
});
const greeting1 = await waitForResponse(socket1, '220');
expect(greeting1).toInclude('220');
console.log('Initial connection successful');
// Send EHLO
socket1.write('EHLO testhost\r\n');
await new Promise<void>((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);
});
await waitForResponse(socket1, '250');
// Complete a transaction
socket1.write('MAIL FROM:<sender@example.com>\r\n');
await new Promise<void>((resolve) => {
socket1.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('250');
resolve();
});
});
const mailResp1 = await waitForResponse(socket1, '250');
expect(mailResp1).toInclude('250');
socket1.write('RCPT TO:<recipient@example.com>\r\n');
await new Promise<void>((resolve) => {
socket1.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('250');
resolve();
});
});
const rcptResp1 = await waitForResponse(socket1, '250');
expect(rcptResp1).toInclude('250');
socket1.write('DATA\r\n');
await new Promise<void>((resolve) => {
socket1.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('354');
resolve();
});
});
const dataResp1 = await waitForResponse(socket1, '354');
expect(dataResp1).toInclude('354');
const emailContent = [
'From: sender@example.com',
@@ -97,16 +101,11 @@ tap.test('REL-02: Restart recovery - Server state after restart', async (tools)
].join('\r\n');
socket1.write(emailContent);
await new Promise<void>((resolve) => {
socket1.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('250');
resolve();
});
});
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');
@@ -141,49 +140,20 @@ tap.test('REL-02: Restart recovery - Server state after restart', async (tools)
// Verify server is fully functional after restart
socket2.write('EHLO testhost-postrestart\r\n');
await new Promise<void>((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);
});
await waitForResponse(socket2, '250');
// Complete another transaction to verify full recovery
socket2.write('MAIL FROM:<sender2@example.com>\r\n');
await new Promise<void>((resolve) => {
socket2.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('250');
resolve();
});
});
const mailResp2 = await waitForResponse(socket2, '250');
expect(mailResp2).toInclude('250');
socket2.write('RCPT TO:<recipient2@example.com>\r\n');
await new Promise<void>((resolve) => {
socket2.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('250');
resolve();
});
});
const rcptResp2 = await waitForResponse(socket2, '250');
expect(rcptResp2).toInclude('250');
socket2.write('DATA\r\n');
await new Promise<void>((resolve) => {
socket2.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('354');
resolve();
});
});
const dataResp2 = await waitForResponse(socket2, '354');
expect(dataResp2).toInclude('354');
const postRestartEmail = [
'From: sender2@example.com',
@@ -196,16 +166,11 @@ tap.test('REL-02: Restart recovery - Server state after restart', async (tools)
].join('\r\n');
socket2.write(postRestartEmail);
await new Promise<void>((resolve) => {
socket2.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('250');
resolve();
});
});
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');
@@ -250,23 +215,19 @@ tap.test('REL-02: Restart recovery - Multiple rapid reconnections', async (tools
});
// Read greeting
const greeting = await new Promise<string>((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 {
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
@@ -307,35 +268,16 @@ tap.test('REL-02: Restart recovery - State persistence check', async (tools) =>
});
// Read greeting
await new Promise<void>((resolve) => {
socket1.once('data', () => resolve());
});
await waitForResponse(socket1, '220');
// Send EHLO
socket1.write('EHLO persistence-test\r\n');
await new Promise<void>((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);
});
await waitForResponse(socket1, '250');
// Start transaction but don't complete it
socket1.write('MAIL FROM:<incomplete@example.com>\r\n');
await new Promise<void>((resolve) => {
socket1.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toInclude('250');
resolve();
});
});
const mailResp = await waitForResponse(socket1, '250');
expect(mailResp).toInclude('250');
// Abruptly close connection
socket1.destroy();
@@ -357,38 +299,20 @@ tap.test('REL-02: Restart recovery - State persistence check', async (tools) =>
});
// Read greeting
await new Promise<void>((resolve) => {
socket2.once('data', () => resolve());
});
await waitForResponse(socket2, '220');
// Send EHLO
socket2.write('EHLO recovery-test\r\n');
await new Promise<void>((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);
});
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 new Promise<string>((resolve) => {
socket2.once('data', (chunk) => {
resolve(chunk.toString());
});
});
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();