333 lines
9.4 KiB
TypeScript
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(); |