dcrouter/test/suite/smtpserver_error-handling/test.err-07.exception-handling.ts
2025-05-25 19:05:43 +00:00

333 lines
9.4 KiB
TypeScript

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;
const activeSockets = new Set<net.Socket>();
tap.test('prepare server', async () => {
testServer = await startTestServer({ port: TEST_PORT });
await new Promise(resolve => setTimeout(resolve, 100));
});
tap.test('ERR-07: Exception handling - Invalid commands', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: 30000
});
activeSockets.add(socket);
socket.on('close', () => activeSockets.delete(socket));
socket.on('connect', async () => {
try {
// Read greeting
await new Promise<void>((resolve) => {
socket.once('data', () => resolve());
});
// Send EHLO
socket.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('\r\n')) {
socket.removeListener('data', handleData);
resolve();
}
};
socket.on('data', handleData);
});
// Test various exception-triggering commands
const invalidCommands = [
'INVALID_COMMAND_THAT_SHOULD_TRIGGER_EXCEPTION',
'MAIL FROM:<>', // Empty address
'RCPT TO:<>', // Empty address
'\x00\x01\x02INVALID_BYTES', // Binary data
'VERY_LONG_COMMAND_' + 'X'.repeat(1000), // Excessively long command
'MAIL FROM', // Missing parameter
'RCPT TO', // Missing parameter
'DATA DATA DATA' // Invalid syntax
];
let exceptionHandled = false;
let serverStillResponding = true;
for (const command of invalidCommands) {
try {
socket.write(command + '\r\n');
const response = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout waiting for response'));
}, 5000);
socket.once('data', (chunk) => {
clearTimeout(timeout);
resolve(chunk.toString());
});
});
console.log(`Command: "${command.substring(0, 50)}..." -> Response: ${response.substring(0, 50)}`);
// Check if server handled the exception properly
if (response.includes('500') || // Command not recognized
response.includes('501') || // Syntax error
response.includes('502') || // Command not implemented
response.includes('503') || // Bad sequence
response.includes('error') ||
response.includes('invalid')) {
exceptionHandled = true;
}
// Small delay between commands
await new Promise(resolve => setTimeout(resolve, 100));
} catch (err) {
console.log('Error with command:', command, err);
// Connection might be closed by server - that's ok for some commands
serverStillResponding = false;
break;
}
}
// If still connected, verify server is still responsive
if (serverStillResponding) {
try {
socket.write('NOOP\r\n');
const noopResponse = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout on NOOP'));
}, 5000);
socket.once('data', (chunk) => {
clearTimeout(timeout);
resolve(chunk.toString());
});
});
if (noopResponse.includes('250')) {
serverStillResponding = true;
}
} catch (err) {
serverStillResponding = false;
}
}
console.log('Exception handled:', exceptionHandled);
console.log('Server still responding:', serverStillResponding);
// Test passes if exceptions were handled OR server is still responding
expect(exceptionHandled || serverStillResponding).toEqual(true);
if (socket.writable) {
socket.write('QUIT\r\n');
}
socket.end();
done.resolve();
} catch (error) {
socket.end();
done.reject(error);
}
});
socket.on('error', (error) => {
done.reject(error);
});
});
tap.test('ERR-07: Exception handling - Malformed protocol', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: 30000
});
activeSockets.add(socket);
socket.on('close', () => activeSockets.delete(socket));
socket.on('connect', async () => {
try {
// Read greeting
await new Promise<void>((resolve) => {
socket.once('data', () => resolve());
});
// Send commands with protocol violations
const protocolViolations = [
'EHLO', // No hostname
'MAIL FROM:<test@example.com> SIZE=', // Incomplete SIZE
'RCPT TO:<test@example.com> NOTIFY=', // Incomplete NOTIFY
'AUTH PLAIN', // No credentials
'STARTTLS EXTRA', // Extra parameters
'MAIL FROM:<test@example.com>\r\nRCPT TO:<test@example.com>', // Multiple commands in one line
];
let violationsHandled = 0;
for (const violation of protocolViolations) {
try {
socket.write(violation + '\r\n');
const response = await new Promise<string>((resolve) => {
const timeout = setTimeout(() => {
resolve('TIMEOUT');
}, 3000);
socket.once('data', (chunk) => {
clearTimeout(timeout);
resolve(chunk.toString());
});
});
if (response !== 'TIMEOUT' &&
(response.includes('500') ||
response.includes('501') ||
response.includes('503'))) {
violationsHandled++;
}
await new Promise(resolve => setTimeout(resolve, 100));
} catch (err) {
// Error is ok - server might close connection
}
}
console.log(`Protocol violations handled: ${violationsHandled}/${protocolViolations.length}`);
// Server should handle at least some violations properly
expect(violationsHandled).toBeGreaterThan(0);
if (socket.writable) {
socket.write('QUIT\r\n');
}
socket.end();
done.resolve();
} catch (error) {
socket.end();
done.reject(error);
}
});
socket.on('error', (error) => {
done.reject(error);
});
});
tap.test('ERR-07: Exception handling - Recovery after errors', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: 30000
});
activeSockets.add(socket);
socket.on('close', () => activeSockets.delete(socket));
socket.on('connect', async () => {
try {
// Read greeting
await new Promise<void>((resolve) => {
socket.once('data', () => resolve());
});
// Send EHLO
socket.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('\r\n')) {
socket.removeListener('data', handleData);
resolve();
}
};
socket.on('data', handleData);
});
// Trigger an error
socket.write('INVALID_COMMAND\r\n');
await new Promise<void>((resolve) => {
socket.once('data', (chunk) => {
const response = chunk.toString();
expect(response).toMatch(/50[0-3]/);
resolve();
});
});
// Now try a valid command sequence to ensure recovery
socket.write('MAIL FROM:<test@example.com>\r\n');
const mailResponse = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => {
resolve(chunk.toString());
});
});
expect(mailResponse).toInclude('250');
socket.write('RCPT TO:<recipient@example.com>\r\n');
const rcptResponse = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => {
resolve(chunk.toString());
});
});
expect(rcptResponse).toInclude('250');
// Server recovered successfully after exception
socket.write('RSET\r\n');
const rsetResponse = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => {
resolve(chunk.toString());
});
});
expect(rsetResponse).toInclude('250');
console.log('Server recovered successfully after exception');
socket.write('QUIT\r\n');
socket.end();
done.resolve();
} catch (error) {
socket.end();
done.reject(error);
}
});
socket.on('error', (error) => {
done.reject(error);
});
});
tap.test('cleanup server', async () => {
// Close any remaining sockets
for (const socket of activeSockets) {
if (!socket.destroyed) {
socket.destroy();
}
}
// Wait for all sockets to be fully closed
await new Promise(resolve => setTimeout(resolve, 500));
await stopTestServer(testServer);
});
export default tap.start();