feat: Add comprehensive SMTP test suite for Deno
- Implemented SMTP client utilities in `test/helpers/smtp.client.ts` for creating test clients, sending emails, and testing connections. - Developed SMTP protocol test utilities in `test/helpers/utils.ts` for managing TCP connections, sending commands, and handling responses. - Created a detailed README in `test/readme.md` outlining the test framework, infrastructure, organization, and running instructions. - Ported CMD-01: EHLO Command tests in `test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts` with multiple scenarios including valid and invalid hostnames. - Ported CMD-02: MAIL FROM Command tests in `test/suite/smtpserver_commands/test.cmd-02.mail-from.test.ts` covering valid address acceptance, invalid address rejection, SIZE parameter support, and command sequence enforcement.
This commit is contained in:
350
test/helpers/utils.ts
Normal file
350
test/helpers/utils.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* SMTP Test Utilities for Deno
|
||||
* Provides helper functions for testing SMTP protocol implementation
|
||||
*/
|
||||
|
||||
import { net } from '../../ts/plugins.ts';
|
||||
|
||||
/**
|
||||
* Test result interface
|
||||
*/
|
||||
export interface ITestResult {
|
||||
success: boolean;
|
||||
duration: number;
|
||||
message?: string;
|
||||
error?: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test configuration interface
|
||||
*/
|
||||
export interface ITestConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
timeout: number;
|
||||
fromAddress?: string;
|
||||
toAddress?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to SMTP server
|
||||
*/
|
||||
export async function connectToSmtp(
|
||||
host: string,
|
||||
port: number,
|
||||
timeout: number = 5000
|
||||
): Promise<Deno.TcpConn> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const conn = await Deno.connect({
|
||||
hostname: host,
|
||||
port,
|
||||
transport: 'tcp',
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
return conn;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(`Connection timeout after ${timeout}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read data from TCP connection with timeout
|
||||
*/
|
||||
async function readWithTimeout(
|
||||
conn: Deno.TcpConn,
|
||||
timeout: number
|
||||
): Promise<string> {
|
||||
const buffer = new Uint8Array(4096);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const n = await conn.read(buffer);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (n === null) {
|
||||
throw new Error('Connection closed');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(buffer.subarray(0, n));
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(`Read timeout after ${timeout}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP command and wait for response
|
||||
*/
|
||||
export async function sendSmtpCommand(
|
||||
conn: Deno.TcpConn,
|
||||
command: string,
|
||||
expectedCode?: string,
|
||||
timeout: number = 5000
|
||||
): Promise<string> {
|
||||
// Send command
|
||||
const encoder = new TextEncoder();
|
||||
await conn.write(encoder.encode(command + '\r\n'));
|
||||
|
||||
// Read response
|
||||
let buffer = '';
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const chunk = await readWithTimeout(conn, timeout - (Date.now() - startTime));
|
||||
buffer += chunk;
|
||||
|
||||
// Check if we have a complete response (ends with \r\n)
|
||||
if (buffer.includes('\r\n')) {
|
||||
if (expectedCode && !buffer.startsWith(expectedCode)) {
|
||||
throw new Error(`Expected ${expectedCode}, got: ${buffer.trim()}`);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Command timeout after ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for SMTP greeting (220 code)
|
||||
*/
|
||||
export async function waitForGreeting(
|
||||
conn: Deno.TcpConn,
|
||||
timeout: number = 5000
|
||||
): Promise<string> {
|
||||
let buffer = '';
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const chunk = await readWithTimeout(conn, timeout - (Date.now() - startTime));
|
||||
buffer += chunk;
|
||||
|
||||
if (buffer.includes('220')) {
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Greeting timeout after ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform SMTP handshake and return capabilities
|
||||
*/
|
||||
export async function performSmtpHandshake(
|
||||
conn: Deno.TcpConn,
|
||||
hostname: string = 'test.example.com'
|
||||
): Promise<string[]> {
|
||||
const capabilities: string[] = [];
|
||||
|
||||
// Wait for greeting
|
||||
await waitForGreeting(conn);
|
||||
|
||||
// Send EHLO
|
||||
const ehloResponse = await sendSmtpCommand(conn, `EHLO ${hostname}`, '250');
|
||||
|
||||
// Parse capabilities
|
||||
const lines = ehloResponse.split('\r\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('250-') || line.startsWith('250 ')) {
|
||||
const capability = line.substring(4).trim();
|
||||
if (capability) {
|
||||
capabilities.push(capability);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple concurrent connections
|
||||
*/
|
||||
export async function createConcurrentConnections(
|
||||
host: string,
|
||||
port: number,
|
||||
count: number,
|
||||
timeout: number = 5000
|
||||
): Promise<Deno.TcpConn[]> {
|
||||
const connectionPromises = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
connectionPromises.push(connectToSmtp(host, port, timeout));
|
||||
}
|
||||
|
||||
return Promise.all(connectionPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close SMTP connection gracefully
|
||||
*/
|
||||
export async function closeSmtpConnection(conn: Deno.TcpConn): Promise<void> {
|
||||
try {
|
||||
await sendSmtpCommand(conn, 'QUIT', '221');
|
||||
} catch {
|
||||
// Ignore errors during QUIT
|
||||
}
|
||||
|
||||
try {
|
||||
conn.close();
|
||||
} catch {
|
||||
// Ignore close errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random email content
|
||||
*/
|
||||
export function generateRandomEmail(size: number = 1024): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 \r\n';
|
||||
let content = '';
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
content += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create MIME message
|
||||
*/
|
||||
export function createMimeMessage(options: {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
attachments?: Array<{ filename: string; content: string; contentType: string }>;
|
||||
}): string {
|
||||
const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
||||
const date = new Date().toUTCString();
|
||||
|
||||
let message = '';
|
||||
message += `From: ${options.from}\r\n`;
|
||||
message += `To: ${options.to}\r\n`;
|
||||
message += `Subject: ${options.subject}\r\n`;
|
||||
message += `Date: ${date}\r\n`;
|
||||
message += `MIME-Version: 1.0\r\n`;
|
||||
|
||||
if (options.attachments && options.attachments.length > 0) {
|
||||
message += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
|
||||
message += '\r\n';
|
||||
|
||||
// Text part
|
||||
if (options.text) {
|
||||
message += `--${boundary}\r\n`;
|
||||
message += 'Content-Type: text/plain; charset=utf-8\r\n';
|
||||
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||
message += '\r\n';
|
||||
message += options.text + '\r\n';
|
||||
}
|
||||
|
||||
// HTML part
|
||||
if (options.html) {
|
||||
message += `--${boundary}\r\n`;
|
||||
message += 'Content-Type: text/html; charset=utf-8\r\n';
|
||||
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||
message += '\r\n';
|
||||
message += options.html + '\r\n';
|
||||
}
|
||||
|
||||
// Attachments
|
||||
const encoder = new TextEncoder();
|
||||
for (const attachment of options.attachments) {
|
||||
message += `--${boundary}\r\n`;
|
||||
message += `Content-Type: ${attachment.contentType}\r\n`;
|
||||
message += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
|
||||
message += 'Content-Transfer-Encoding: base64\r\n';
|
||||
message += '\r\n';
|
||||
// Convert to base64
|
||||
const bytes = encoder.encode(attachment.content);
|
||||
const base64 = btoa(String.fromCharCode(...bytes));
|
||||
message += base64 + '\r\n';
|
||||
}
|
||||
|
||||
message += `--${boundary}--\r\n`;
|
||||
} else if (options.html && options.text) {
|
||||
const altBoundary = `----=_Alt_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
||||
message += `Content-Type: multipart/alternative; boundary="${altBoundary}"\r\n`;
|
||||
message += '\r\n';
|
||||
|
||||
// Text part
|
||||
message += `--${altBoundary}\r\n`;
|
||||
message += 'Content-Type: text/plain; charset=utf-8\r\n';
|
||||
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||
message += '\r\n';
|
||||
message += options.text + '\r\n';
|
||||
|
||||
// HTML part
|
||||
message += `--${altBoundary}\r\n`;
|
||||
message += 'Content-Type: text/html; charset=utf-8\r\n';
|
||||
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||
message += '\r\n';
|
||||
message += options.html + '\r\n';
|
||||
|
||||
message += `--${altBoundary}--\r\n`;
|
||||
} else if (options.html) {
|
||||
message += 'Content-Type: text/html; charset=utf-8\r\n';
|
||||
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||
message += '\r\n';
|
||||
message += options.html;
|
||||
} else {
|
||||
message += 'Content-Type: text/plain; charset=utf-8\r\n';
|
||||
message += 'Content-Transfer-Encoding: 8bit\r\n';
|
||||
message += '\r\n';
|
||||
message += options.text || '';
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure operation time
|
||||
*/
|
||||
export async function measureTime<T>(
|
||||
operation: () => Promise<T>
|
||||
): Promise<{ result: T; duration: number }> {
|
||||
const startTime = Date.now();
|
||||
const result = await operation();
|
||||
const duration = Date.now() - startTime;
|
||||
return { result, duration };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry operation with exponential backoff
|
||||
*/
|
||||
export async function retryOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
initialDelay: number = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
if (i < maxRetries - 1) {
|
||||
const delay = initialDelay * Math.pow(2, i);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!;
|
||||
}
|
||||
Reference in New Issue
Block a user