395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
import { tap, expect } 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 TEST_TIMEOUT = 15000;
|
|
|
|
tap.test('prepare server', async () => {
|
|
testServer = await startTestServer({ port: TEST_PORT });
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
});
|
|
|
|
tap.test('DATA - should accept email data after RCPT TO', 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';
|
|
receivedData = '';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
currentStep = 'mail_from';
|
|
receivedData = '';
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
currentStep = 'rcpt_to';
|
|
receivedData = '';
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
currentStep = 'data_command';
|
|
receivedData = '';
|
|
socket.write('DATA\r\n');
|
|
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
currentStep = 'message_body';
|
|
receivedData = '';
|
|
// Send email content
|
|
socket.write('From: sender@example.com\r\n');
|
|
socket.write('To: recipient@example.com\r\n');
|
|
socket.write('Subject: Test message\r\n');
|
|
socket.write('\r\n'); // Empty line to separate headers from body
|
|
socket.write('This is a test message.\r\n');
|
|
socket.write('.\r\n'); // End of message
|
|
} else if (currentStep === 'message_body' && receivedData.includes('250')) {
|
|
expect(receivedData).toInclude('250');
|
|
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;
|
|
});
|
|
|
|
tap.test('DATA - should reject without RCPT TO', 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';
|
|
receivedData = '';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
currentStep = 'data_without_rcpt';
|
|
receivedData = '';
|
|
// Try DATA without MAIL FROM or RCPT TO
|
|
socket.write('DATA\r\n');
|
|
} else if (currentStep === 'data_without_rcpt' && receivedData.includes('503')) {
|
|
// Should get 503 (bad sequence)
|
|
expect(receivedData).toInclude('503');
|
|
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;
|
|
});
|
|
|
|
tap.test('DATA - should accept empty message body', 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';
|
|
receivedData = '';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
currentStep = 'mail_from';
|
|
receivedData = '';
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
currentStep = 'rcpt_to';
|
|
receivedData = '';
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
currentStep = 'data_command';
|
|
receivedData = '';
|
|
socket.write('DATA\r\n');
|
|
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
currentStep = 'empty_message';
|
|
receivedData = '';
|
|
// Send only the terminator
|
|
socket.write('.\r\n');
|
|
} else if (currentStep === 'empty_message') {
|
|
// Server should accept empty message
|
|
expect(receivedData).toMatch(/^(250|5\d\d)/);
|
|
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;
|
|
});
|
|
|
|
tap.test('DATA - should handle dot stuffing correctly', 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';
|
|
receivedData = '';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
currentStep = 'mail_from';
|
|
receivedData = '';
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
currentStep = 'rcpt_to';
|
|
receivedData = '';
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
currentStep = 'data_command';
|
|
receivedData = '';
|
|
socket.write('DATA\r\n');
|
|
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
currentStep = 'dot_stuffed_message';
|
|
receivedData = '';
|
|
// Send message with dots that need stuffing
|
|
socket.write('This line is normal.\r\n');
|
|
socket.write('..This line starts with two dots (one will be removed).\r\n');
|
|
socket.write('.This line starts with a single dot.\r\n');
|
|
socket.write('...This line starts with three dots.\r\n');
|
|
socket.write('.\r\n'); // End of message
|
|
} else if (currentStep === 'dot_stuffed_message' && receivedData.includes('250')) {
|
|
expect(receivedData).toInclude('250');
|
|
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;
|
|
});
|
|
|
|
tap.test('DATA - should handle large messages', 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';
|
|
receivedData = '';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
currentStep = 'mail_from';
|
|
receivedData = '';
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
currentStep = 'rcpt_to';
|
|
receivedData = '';
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
currentStep = 'data_command';
|
|
receivedData = '';
|
|
socket.write('DATA\r\n');
|
|
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
currentStep = 'large_message';
|
|
receivedData = '';
|
|
// Send a large message (100KB)
|
|
socket.write('From: sender@example.com\r\n');
|
|
socket.write('To: recipient@example.com\r\n');
|
|
socket.write('Subject: Large test message\r\n');
|
|
socket.write('\r\n');
|
|
|
|
// Generate 100KB of data
|
|
const lineContent = 'This is a test line that will be repeated many times. ';
|
|
const linesNeeded = Math.ceil(100000 / lineContent.length);
|
|
|
|
for (let i = 0; i < linesNeeded; i++) {
|
|
socket.write(lineContent + '\r\n');
|
|
}
|
|
|
|
socket.write('.\r\n'); // End of message
|
|
} else if (currentStep === 'large_message' && receivedData.includes('250')) {
|
|
expect(receivedData).toInclude('250');
|
|
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;
|
|
});
|
|
|
|
tap.test('DATA - should handle binary data in message', 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';
|
|
receivedData = '';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
|
|
currentStep = 'mail_from';
|
|
receivedData = '';
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
currentStep = 'rcpt_to';
|
|
receivedData = '';
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
currentStep = 'data_command';
|
|
receivedData = '';
|
|
socket.write('DATA\r\n');
|
|
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
|
|
currentStep = 'binary_message';
|
|
receivedData = '';
|
|
// Send message with binary data (base64 encoded attachment)
|
|
socket.write('From: sender@example.com\r\n');
|
|
socket.write('To: recipient@example.com\r\n');
|
|
socket.write('Subject: Binary test message\r\n');
|
|
socket.write('MIME-Version: 1.0\r\n');
|
|
socket.write('Content-Type: multipart/mixed; boundary="boundary123"\r\n');
|
|
socket.write('\r\n');
|
|
socket.write('--boundary123\r\n');
|
|
socket.write('Content-Type: text/plain\r\n');
|
|
socket.write('\r\n');
|
|
socket.write('This message contains binary data.\r\n');
|
|
socket.write('--boundary123\r\n');
|
|
socket.write('Content-Type: application/octet-stream\r\n');
|
|
socket.write('Content-Transfer-Encoding: base64\r\n');
|
|
socket.write('\r\n');
|
|
socket.write('SGVsbG8gV29ybGQhIFRoaXMgaXMgYmluYXJ5IGRhdGEu\r\n');
|
|
socket.write('--boundary123--\r\n');
|
|
socket.write('.\r\n'); // End of message
|
|
} else if (currentStep === 'binary_message' && receivedData.includes('250')) {
|
|
expect(receivedData).toInclude('250');
|
|
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;
|
|
});
|
|
|
|
tap.test('cleanup server', async () => {
|
|
await stopTestServer(testServer);
|
|
});
|
|
|
|
export default tap.start(); |