431 lines
12 KiB
TypeScript
431 lines
12 KiB
TypeScript
import { tap, expect } from '@git.zone/tapbundle';
|
|
import * as net from 'net';
|
|
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
|
|
const TEST_PORT = 30036;
|
|
const TEST_TIMEOUT = 30000;
|
|
|
|
let testServer: ITestServer;
|
|
|
|
tap.test('setup - start SMTP server for empty command tests', async () => {
|
|
testServer = await startTestServer({
|
|
port: TEST_PORT,
|
|
hostname: 'localhost'
|
|
});
|
|
expect(testServer).toBeInstanceOf(Object);
|
|
});
|
|
|
|
tap.test('Empty Commands - should reject empty line (just CRLF)', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
try {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', () => resolve());
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
// Get banner
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Send EHLO first
|
|
socket.write('EHLO testhost\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
});
|
|
|
|
// Send empty line (just CRLF)
|
|
socket.write('\r\n');
|
|
|
|
const response = await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
setTimeout(() => resolve('TIMEOUT'), 2000);
|
|
});
|
|
|
|
console.log('Response to empty line:', response);
|
|
|
|
// Should get syntax error (500, 501, or 502)
|
|
if (response !== 'TIMEOUT') {
|
|
expect(response).toMatch(/^5\d{2}/);
|
|
} else {
|
|
// Server might ignore empty lines
|
|
console.log('Server ignored empty line');
|
|
expect(true).toBeTrue();
|
|
}
|
|
|
|
// Test server is still responsive
|
|
socket.write('NOOP\r\n');
|
|
const noopResponse = await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
expect(noopResponse).toInclude('250');
|
|
|
|
// Clean up
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
|
|
} finally {
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('Empty Commands - should reject commands with only whitespace', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
try {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', () => resolve());
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
// Get banner and send EHLO
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('EHLO testhost\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
});
|
|
|
|
// Test various whitespace-only commands
|
|
const whitespaceCommands = [
|
|
' \r\n', // Spaces only
|
|
'\t\r\n', // Tab only
|
|
' \t \r\n', // Mixed whitespace
|
|
' \r\n' // Multiple spaces
|
|
];
|
|
|
|
for (const cmd of whitespaceCommands) {
|
|
socket.write(cmd);
|
|
|
|
const response = await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
setTimeout(() => resolve('TIMEOUT'), 2000);
|
|
});
|
|
|
|
console.log(`Response to whitespace "${cmd.trim()}"\\r\\n:`, response);
|
|
|
|
if (response !== 'TIMEOUT') {
|
|
// Should get syntax error
|
|
expect(response).toMatch(/^5\d{2}/);
|
|
}
|
|
}
|
|
|
|
// Verify server still works
|
|
socket.write('NOOP\r\n');
|
|
const noopResponse = await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
expect(noopResponse).toInclude('250');
|
|
|
|
// Clean up
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
|
|
} finally {
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('Empty Commands - should reject MAIL FROM with empty parameter', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
try {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', () => resolve());
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
// Setup connection
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('EHLO testhost\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
});
|
|
|
|
// Send MAIL FROM with empty parameter
|
|
socket.write('MAIL FROM:\r\n');
|
|
|
|
const response = await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
console.log('Response to empty MAIL FROM:', response);
|
|
|
|
// Should get syntax error (501 or 550)
|
|
expect(response).toMatch(/^5\d{2}/);
|
|
expect(response.toLowerCase()).toMatch(/syntax|parameter|address/);
|
|
|
|
// Clean up
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
|
|
} finally {
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('Empty Commands - should reject RCPT TO with empty parameter', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
try {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', () => resolve());
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
// Setup connection
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('EHLO testhost\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
});
|
|
|
|
// Send valid MAIL FROM first
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Send RCPT TO with empty parameter
|
|
socket.write('RCPT TO:\r\n');
|
|
|
|
const response = await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
console.log('Response to empty RCPT TO:', response);
|
|
|
|
// Should get syntax error
|
|
expect(response).toMatch(/^5\d{2}/);
|
|
expect(response.toLowerCase()).toMatch(/syntax|parameter|address/);
|
|
|
|
// Clean up
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
|
|
} finally {
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('Empty Commands - should reject EHLO/HELO without hostname', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
try {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', () => resolve());
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
// Get banner
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Send EHLO without hostname
|
|
socket.write('EHLO\r\n');
|
|
|
|
const ehloResponse = await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
console.log('Response to EHLO without hostname:', ehloResponse);
|
|
|
|
// Should get syntax error
|
|
expect(ehloResponse).toMatch(/^5\d{2}/);
|
|
|
|
// Try HELO without hostname
|
|
socket.write('HELO\r\n');
|
|
|
|
const heloResponse = await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
console.log('Response to HELO without hostname:', heloResponse);
|
|
|
|
// Should get syntax error
|
|
expect(heloResponse).toMatch(/^5\d{2}/);
|
|
|
|
// Send valid EHLO to establish session
|
|
socket.write('EHLO testhost\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
});
|
|
|
|
// Clean up
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
|
|
} finally {
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('Empty Commands - server should remain stable after empty commands', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
try {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', () => resolve());
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
// Get banner
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Send EHLO
|
|
socket.write('EHLO testhost\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
});
|
|
|
|
// Send multiple empty/invalid commands
|
|
const invalidCommands = [
|
|
'\r\n',
|
|
' \r\n',
|
|
'MAIL FROM:\r\n',
|
|
'RCPT TO:\r\n',
|
|
'EHLO\r\n',
|
|
'\t\r\n'
|
|
];
|
|
|
|
for (const cmd of invalidCommands) {
|
|
socket.write(cmd);
|
|
|
|
// Read response but don't fail if error
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
setTimeout(() => resolve('TIMEOUT'), 1000);
|
|
});
|
|
}
|
|
|
|
// Now test that server is still functional
|
|
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');
|
|
|
|
console.log('Server remained stable after multiple empty commands');
|
|
|
|
// Clean up
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
|
|
} finally {
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('cleanup - stop SMTP server', async () => {
|
|
await stopTestServer(testServer);
|
|
expect(true).toBeTrue();
|
|
});
|
|
|
|
tap.start(); |