475 lines
14 KiB
TypeScript
475 lines
14 KiB
TypeScript
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
import * as net from 'net';
|
|
import * as path from 'path';
|
|
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
|
|
// Test configuration
|
|
const TEST_PORT = 2525;
|
|
const TEST_TIMEOUT = 10000;
|
|
|
|
let testServer: ITestServer;
|
|
|
|
// Setup
|
|
tap.test('setup - start SMTP server', async () => {
|
|
testServer = await startTestServer({
|
|
port: TEST_PORT,
|
|
tlsEnabled: false,
|
|
hostname: 'localhost'
|
|
});
|
|
|
|
expect(testServer).toBeDefined();
|
|
expect(testServer.port).toEqual(TEST_PORT);
|
|
});
|
|
|
|
// Test: Invalid command
|
|
tap.test('Syntax Errors - should reject invalid command', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'invalid_command';
|
|
socket.write('INVALID_COMMAND\r\n');
|
|
} else if (currentStep === 'invalid_command' && receivedData.match(/[45]\d{2}/)) {
|
|
// Extract response code immediately after receiving error response
|
|
const lines = receivedData.split('\r\n');
|
|
// Find the last line that starts with 4xx or 5xx
|
|
let errorCode = '';
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
if (match) {
|
|
errorCode = match[1];
|
|
break;
|
|
}
|
|
}
|
|
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
// Expect 500 (syntax error) or 502 (command not implemented)
|
|
expect(errorCode).toMatch(/^(500|502)$/);
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: MAIL FROM without brackets
|
|
tap.test('Syntax Errors - should reject MAIL FROM without brackets', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'ehlo';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
currentStep = 'mail_from_no_brackets';
|
|
socket.write('MAIL FROM:test@example.com\r\n'); // Missing angle brackets
|
|
} else if (currentStep === 'mail_from_no_brackets' && receivedData.match(/[45]\d{2}/)) {
|
|
// Extract the most recent error response code
|
|
const lines = receivedData.split('\r\n');
|
|
let responseCode = '';
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
if (match) {
|
|
responseCode = match[1];
|
|
break;
|
|
}
|
|
}
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
// Expect 501 (syntax error in parameters)
|
|
expect(responseCode).toEqual('501');
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: RCPT TO without brackets
|
|
tap.test('Syntax Errors - should reject RCPT TO without brackets', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'ehlo';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
currentStep = 'mail_from';
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
currentStep = 'rcpt_to_no_brackets';
|
|
socket.write('RCPT TO:recipient@example.com\r\n'); // Missing angle brackets
|
|
} else if (currentStep === 'rcpt_to_no_brackets' && receivedData.match(/[45]\d{2}/)) {
|
|
// Extract the most recent error response code
|
|
const lines = receivedData.split('\r\n');
|
|
let responseCode = '';
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
if (match) {
|
|
responseCode = match[1];
|
|
break;
|
|
}
|
|
}
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
// Expect 501 (syntax error in parameters)
|
|
expect(responseCode).toEqual('501');
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: EHLO without hostname
|
|
tap.test('Syntax Errors - should reject EHLO without hostname', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'ehlo_no_hostname';
|
|
socket.write('EHLO\r\n'); // Missing hostname
|
|
} else if (currentStep === 'ehlo_no_hostname' && receivedData.match(/[45]\d{2}/)) {
|
|
// Extract the most recent error response code
|
|
const lines = receivedData.split('\r\n');
|
|
let responseCode = '';
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
if (match) {
|
|
responseCode = match[1];
|
|
break;
|
|
}
|
|
}
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
// Expect 501 (syntax error in parameters)
|
|
expect(responseCode).toEqual('501');
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: Command with extra parameters
|
|
tap.test('Syntax Errors - should handle commands with extra parameters', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'ehlo';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
currentStep = 'quit_extra';
|
|
socket.write('QUIT extra parameters\r\n'); // QUIT doesn't take parameters
|
|
} else if (currentStep === 'quit_extra') {
|
|
// Extract the most recent response code (could be 221 or error)
|
|
const lines = receivedData.split('\r\n');
|
|
let responseCode = '';
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const match = lines[i].match(/^([2-5]\d{2})\s/);
|
|
if (match) {
|
|
responseCode = match[1];
|
|
break;
|
|
}
|
|
}
|
|
socket.destroy();
|
|
// Some servers might accept it (221) or reject it (501)
|
|
expect(responseCode).toMatch(/^(221|501)$/);
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: Malformed addresses
|
|
tap.test('Syntax Errors - should reject malformed email addresses', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'ehlo';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
currentStep = 'mail_from_malformed';
|
|
socket.write('MAIL FROM:<not an email>\r\n'); // Malformed address
|
|
} else if (currentStep === 'mail_from_malformed' && receivedData.match(/[45]\d{2}/)) {
|
|
// Extract the most recent error response code
|
|
const lines = receivedData.split('\r\n');
|
|
let responseCode = '';
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
if (match) {
|
|
responseCode = match[1];
|
|
break;
|
|
}
|
|
}
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
// Expect 501 or 553 (bad address)
|
|
expect(responseCode).toMatch(/^(501|553)$/);
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: Commands in wrong order
|
|
tap.test('Syntax Errors - should reject commands in wrong sequence', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'data_without_rcpt';
|
|
socket.write('DATA\r\n'); // DATA without MAIL FROM/RCPT TO
|
|
} else if (currentStep === 'data_without_rcpt' && receivedData.match(/[45]\d{2}/)) {
|
|
// Extract the most recent error response code
|
|
const lines = receivedData.split('\r\n');
|
|
let responseCode = '';
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const match = lines[i].match(/^([45]\d{2})\s/);
|
|
if (match) {
|
|
responseCode = match[1];
|
|
break;
|
|
}
|
|
}
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
// Expect 503 (bad sequence of commands)
|
|
expect(responseCode).toEqual('503');
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: Long commands
|
|
tap.test('Syntax Errors - should handle excessively long commands', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
const longString = 'A'.repeat(1000); // Very long string
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'long_command';
|
|
socket.write(`EHLO ${longString}\r\n`); // Excessively long hostname
|
|
} else if (currentStep === 'long_command') {
|
|
// Wait for complete response (including all continuation lines)
|
|
if (receivedData.includes('250 ') || receivedData.match(/[45]\d{2}\s/)) {
|
|
currentStep = 'done';
|
|
|
|
// The server accepted the long EHLO command with 250
|
|
// Some servers might reject with 500/501
|
|
// Since we see 250 in the logs, the server accepts it
|
|
const hasError = receivedData.match(/([45]\d{2})\s/);
|
|
const hasSuccess = receivedData.includes('250 ');
|
|
|
|
// Determine the response code
|
|
let responseCode = '';
|
|
if (hasError) {
|
|
responseCode = hasError[1];
|
|
} else if (hasSuccess) {
|
|
responseCode = '250';
|
|
}
|
|
|
|
// Some servers accept long hostnames, others reject them
|
|
// Accept either 250 (ok), 500 (syntax error), or 501 (line too long)
|
|
expect(responseCode).toMatch(/^(250|500|501)$/);
|
|
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Teardown
|
|
tap.test('teardown - stop SMTP server', async () => {
|
|
if (testServer) {
|
|
await stopTestServer(testServer);
|
|
}
|
|
});
|
|
|
|
// Start the test
|
|
export default tap.start(); |