dcrouter/test/suite/smtpclient_rfc-compliance/test.crfc-05.state-machine.ts
2025-05-26 14:50:55 +00:00

703 lines
25 KiB
TypeScript

import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createTestServer } from '../../helpers/server.loader.js';
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
import { Email } from '../../../ts/index.js';
tap.test('CRFC-05: should comply with SMTP state machine (RFC 5321)', async (tools) => {
const testId = 'CRFC-05-state-machine';
console.log(`\n${testId}: Testing SMTP state machine compliance...`);
let scenarioCount = 0;
// Scenario 1: Initial state and greeting
await (async () => {
scenarioCount++;
console.log(`\nScenario ${scenarioCount}: Testing initial state and greeting`);
const testServer = await createTestServer({
onConnection: async (socket) => {
console.log(' [Server] Client connected - Initial state');
let state = 'initial';
// Send greeting immediately upon connection
socket.write('220 statemachine.example.com ESMTP Service ready\r\n');
state = 'greeting-sent';
console.log(' [Server] State: initial -> greeting-sent');
socket.on('data', (data) => {
const command = data.toString().trim();
console.log(` [Server] State: ${state}, Received: ${command}`);
if (state === 'greeting-sent') {
if (command.startsWith('EHLO') || command.startsWith('HELO')) {
socket.write('250 statemachine.example.com\r\n');
state = 'ready';
console.log(' [Server] State: greeting-sent -> ready');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('503 5.5.1 Bad sequence of commands\r\n');
}
} else if (state === 'ready') {
if (command.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
state = 'mail';
console.log(' [Server] State: ready -> mail');
} else if (command.startsWith('EHLO') || command.startsWith('HELO')) {
socket.write('250 statemachine.example.com\r\n');
// Stay in ready state
} else if (command === 'RSET' || command === 'NOOP') {
socket.write('250 OK\r\n');
// Stay in ready state
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('503 5.5.1 Bad sequence of commands\r\n');
}
}
});
}
});
const smtpClient = createTestSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false
});
// Just establish connection and send EHLO
try {
await smtpClient.verify();
console.log(' Initial state transition (connect -> EHLO) successful');
} catch (error) {
console.log(` Connection/EHLO failed: ${error.message}`);
}
await testServer.server.close();
})();
// Scenario 2: Transaction state machine
await (async () => {
scenarioCount++;
console.log(`\nScenario ${scenarioCount}: Testing transaction state machine`);
const testServer = await createTestServer({
onConnection: async (socket) => {
console.log(' [Server] Client connected');
socket.write('220 statemachine.example.com ESMTP\r\n');
let state = 'ready';
socket.on('data', (data) => {
const command = data.toString().trim();
console.log(` [Server] State: ${state}, Command: ${command}`);
switch (state) {
case 'ready':
if (command.startsWith('EHLO')) {
socket.write('250 statemachine.example.com\r\n');
// Stay in ready
} else if (command.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
state = 'mail';
console.log(' [Server] State: ready -> mail');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('503 5.5.1 Bad sequence of commands\r\n');
}
break;
case 'mail':
if (command.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
state = 'rcpt';
console.log(' [Server] State: mail -> rcpt');
} else if (command === 'RSET') {
socket.write('250 OK\r\n');
state = 'ready';
console.log(' [Server] State: mail -> ready (RSET)');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('503 5.5.1 Bad sequence of commands\r\n');
}
break;
case 'rcpt':
if (command.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
// Stay in rcpt (can have multiple recipients)
} else if (command === 'DATA') {
socket.write('354 Start mail input\r\n');
state = 'data';
console.log(' [Server] State: rcpt -> data');
} else if (command === 'RSET') {
socket.write('250 OK\r\n');
state = 'ready';
console.log(' [Server] State: rcpt -> ready (RSET)');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('503 5.5.1 Bad sequence of commands\r\n');
}
break;
case 'data':
if (command === '.') {
socket.write('250 OK\r\n');
state = 'ready';
console.log(' [Server] State: data -> ready (message complete)');
} else if (command === 'QUIT') {
// QUIT is not allowed during DATA
socket.write('503 5.5.1 Bad sequence of commands\r\n');
}
// All other input during DATA is message content
break;
}
});
}
});
const smtpClient = createTestSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false
});
const email = new Email({
from: 'sender@example.com',
to: ['recipient1@example.com', 'recipient2@example.com'],
subject: 'State machine test',
text: 'Testing SMTP transaction state machine'
});
const result = await smtpClient.sendMail(email);
console.log(' Complete transaction state sequence successful');
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
await testServer.server.close();
})();
// Scenario 3: Invalid state transitions
await (async () => {
scenarioCount++;
console.log(`\nScenario ${scenarioCount}: Testing invalid state transitions`);
const testServer = await createTestServer({
onConnection: async (socket) => {
console.log(' [Server] Client connected');
socket.write('220 statemachine.example.com ESMTP\r\n');
let state = 'ready';
socket.on('data', (data) => {
const command = data.toString().trim();
console.log(` [Server] State: ${state}, Command: ${command}`);
// Strictly enforce state machine
switch (state) {
case 'ready':
if (command.startsWith('EHLO') || command.startsWith('HELO')) {
socket.write('250 statemachine.example.com\r\n');
} else if (command.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
state = 'mail';
} else if (command === 'RSET' || command === 'NOOP') {
socket.write('250 OK\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else if (command.startsWith('RCPT TO:')) {
console.log(' [Server] RCPT TO without MAIL FROM');
socket.write('503 5.5.1 Need MAIL command first\r\n');
} else if (command === 'DATA') {
console.log(' [Server] DATA without MAIL FROM and RCPT TO');
socket.write('503 5.5.1 Need MAIL and RCPT commands first\r\n');
} else {
socket.write('503 5.5.1 Bad sequence of commands\r\n');
}
break;
case 'mail':
if (command.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
state = 'rcpt';
} else if (command.startsWith('MAIL FROM:')) {
console.log(' [Server] Second MAIL FROM without RSET');
socket.write('503 5.5.1 Sender already specified\r\n');
} else if (command === 'DATA') {
console.log(' [Server] DATA without RCPT TO');
socket.write('503 5.5.1 Need RCPT command first\r\n');
} else if (command === 'RSET') {
socket.write('250 OK\r\n');
state = 'ready';
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('503 5.5.1 Bad sequence of commands\r\n');
}
break;
case 'rcpt':
if (command.startsWith('RCPT TO:')) {
socket.write('250 OK\r\n');
} else if (command === 'DATA') {
socket.write('354 Start mail input\r\n');
state = 'data';
} else if (command.startsWith('MAIL FROM:')) {
console.log(' [Server] MAIL FROM after RCPT TO without RSET');
socket.write('503 5.5.1 Sender already specified\r\n');
} else if (command === 'RSET') {
socket.write('250 OK\r\n');
state = 'ready';
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('503 5.5.1 Bad sequence of commands\r\n');
}
break;
case 'data':
if (command === '.') {
socket.write('250 OK\r\n');
state = 'ready';
} else if (command.startsWith('MAIL FROM:') ||
command.startsWith('RCPT TO:') ||
command === 'RSET') {
console.log(' [Server] SMTP command during DATA mode');
socket.write('503 5.5.1 Commands not allowed during data transfer\r\n');
}
// During DATA, most input is treated as message content
break;
}
});
}
});
// We'll create a custom client to send invalid command sequences
const testCases = [
{
name: 'RCPT without MAIL',
commands: ['EHLO client.example.com', 'RCPT TO:<test@example.com>'],
expectError: true
},
{
name: 'DATA without RCPT',
commands: ['EHLO client.example.com', 'MAIL FROM:<sender@example.com>', 'DATA'],
expectError: true
},
{
name: 'Double MAIL FROM',
commands: ['EHLO client.example.com', 'MAIL FROM:<sender1@example.com>', 'MAIL FROM:<sender2@example.com>'],
expectError: true
}
];
for (const testCase of testCases) {
console.log(` Testing: ${testCase.name}`);
try {
// Create simple socket connection for manual command testing
const net = await import('net');
const client = net.createConnection(testServer.port, testServer.hostname);
let responseCount = 0;
let errorReceived = false;
client.on('data', (data) => {
const response = data.toString();
console.log(` Response: ${response.trim()}`);
if (response.startsWith('5')) {
errorReceived = true;
}
responseCount++;
if (responseCount <= testCase.commands.length) {
const command = testCase.commands[responseCount - 1];
if (command) {
setTimeout(() => {
console.log(` Sending: ${command}`);
client.write(command + '\r\n');
}, 100);
}
} else {
client.write('QUIT\r\n');
client.end();
}
});
await new Promise((resolve, reject) => {
client.on('end', () => {
if (testCase.expectError && errorReceived) {
console.log(` ✓ Expected error received`);
} else if (!testCase.expectError && !errorReceived) {
console.log(` ✓ No error as expected`);
} else {
console.log(` ✗ Unexpected result`);
}
resolve(void 0);
});
client.on('error', reject);
// Start with greeting response
setTimeout(() => {
if (testCase.commands.length > 0) {
console.log(` Sending: ${testCase.commands[0]}`);
client.write(testCase.commands[0] + '\r\n');
}
}, 100);
});
} catch (error) {
console.log(` Error testing ${testCase.name}: ${error.message}`);
}
}
await testServer.server.close();
})();
// Scenario 4: RSET command state transitions
await (async () => {
scenarioCount++;
console.log(`\nScenario ${scenarioCount}: Testing RSET command state transitions`);
const testServer = await createTestServer({
onConnection: async (socket) => {
console.log(' [Server] Client connected');
socket.write('220 statemachine.example.com ESMTP\r\n');
let state = 'ready';
socket.on('data', (data) => {
const command = data.toString().trim();
console.log(` [Server] State: ${state}, Command: ${command}`);
if (command.startsWith('EHLO')) {
socket.write('250 statemachine.example.com\r\n');
state = 'ready';
} else if (command.startsWith('MAIL FROM:')) {
socket.write('250 OK\r\n');
state = 'mail';
} else if (command.startsWith('RCPT TO:')) {
if (state === 'mail' || state === 'rcpt') {
socket.write('250 OK\r\n');
state = 'rcpt';
} else {
socket.write('503 5.5.1 Bad sequence of commands\r\n');
}
} else if (command === 'RSET') {
console.log(` [Server] RSET from state: ${state} -> ready`);
socket.write('250 OK\r\n');
state = 'ready';
} else if (command === 'DATA') {
if (state === 'rcpt') {
socket.write('354 Start mail input\r\n');
state = 'data';
} else {
socket.write('503 5.5.1 Bad sequence of commands\r\n');
}
} else if (command === '.') {
if (state === 'data') {
socket.write('250 OK\r\n');
state = 'ready';
}
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else if (command === 'NOOP') {
socket.write('250 OK\r\n');
}
});
}
});
const smtpClient = createTestSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false
});
// Test RSET at various points in transaction
console.log(' Testing RSET from different states...');
// We'll manually test RSET behavior
const net = await import('net');
const client = net.createConnection(testServer.port, testServer.hostname);
const commands = [
'EHLO client.example.com', // -> ready
'MAIL FROM:<sender@example.com>', // -> mail
'RSET', // -> ready (reset from mail state)
'MAIL FROM:<sender2@example.com>', // -> mail
'RCPT TO:<rcpt1@example.com>', // -> rcpt
'RCPT TO:<rcpt2@example.com>', // -> rcpt (multiple recipients)
'RSET', // -> ready (reset from rcpt state)
'MAIL FROM:<sender3@example.com>', // -> mail (fresh transaction)
'RCPT TO:<rcpt3@example.com>', // -> rcpt
'DATA', // -> data
'.', // -> ready (complete transaction)
'QUIT'
];
let commandIndex = 0;
client.on('data', (data) => {
const response = data.toString().trim();
console.log(` Response: ${response}`);
if (commandIndex < commands.length) {
setTimeout(() => {
const command = commands[commandIndex];
console.log(` Sending: ${command}`);
if (command === 'DATA') {
client.write(command + '\r\n');
// Send message content immediately after DATA
setTimeout(() => {
client.write('Subject: RSET test\r\n\r\nTesting RSET state transitions.\r\n.\r\n');
}, 100);
} else {
client.write(command + '\r\n');
}
commandIndex++;
}, 100);
} else {
client.end();
}
});
await new Promise((resolve, reject) => {
client.on('end', () => {
console.log(' RSET state transitions completed successfully');
resolve(void 0);
});
client.on('error', reject);
});
await testServer.server.close();
})();
// Scenario 5: Connection state persistence
await (async () => {
scenarioCount++;
console.log(`\nScenario ${scenarioCount}: Testing connection state persistence`);
const testServer = await createTestServer({
onConnection: async (socket) => {
console.log(' [Server] Client connected');
socket.write('220 statemachine.example.com ESMTP\r\n');
let state = 'ready';
let messageCount = 0;
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250-statemachine.example.com\r\n');
socket.write('250 PIPELINING\r\n');
state = 'ready';
} else if (command.startsWith('MAIL FROM:')) {
if (state === 'ready') {
socket.write('250 OK\r\n');
state = 'mail';
} else {
socket.write('503 5.5.1 Bad sequence\r\n');
}
} else if (command.startsWith('RCPT TO:')) {
if (state === 'mail' || state === 'rcpt') {
socket.write('250 OK\r\n');
state = 'rcpt';
} else {
socket.write('503 5.5.1 Bad sequence\r\n');
}
} else if (command === 'DATA') {
if (state === 'rcpt') {
socket.write('354 Start mail input\r\n');
state = 'data';
} else {
socket.write('503 5.5.1 Bad sequence\r\n');
}
} else if (command === '.') {
if (state === 'data') {
messageCount++;
console.log(` [Server] Message ${messageCount} completed`);
socket.write(`250 OK: Message ${messageCount} accepted\r\n`);
state = 'ready';
}
} else if (command === 'QUIT') {
console.log(` [Server] Session ended after ${messageCount} messages`);
socket.write('221 Bye\r\n');
socket.end();
}
});
}
});
const smtpClient = createTestSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
pool: true,
maxConnections: 1
});
// Send multiple emails through same connection
for (let i = 1; i <= 3; i++) {
const email = new Email({
from: 'sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Persistence test ${i}`,
text: `Testing connection state persistence - message ${i}`
});
const result = await smtpClient.sendMail(email);
console.log(` Message ${i} sent successfully`);
expect(result).toBeDefined();
expect(result.response).toContain(`Message ${i}`);
}
// Close the pooled connection
await smtpClient.close();
await testServer.server.close();
})();
// Scenario 6: Error state recovery
await (async () => {
scenarioCount++;
console.log(`\nScenario ${scenarioCount}: Testing error state recovery`);
const testServer = await createTestServer({
onConnection: async (socket) => {
console.log(' [Server] Client connected');
socket.write('220 statemachine.example.com ESMTP\r\n');
let state = 'ready';
let errorCount = 0;
socket.on('data', (data) => {
const command = data.toString().trim();
console.log(` [Server] State: ${state}, Command: ${command}`);
if (command.startsWith('EHLO')) {
socket.write('250 statemachine.example.com\r\n');
state = 'ready';
errorCount = 0; // Reset error count on new session
} else if (command.startsWith('MAIL FROM:')) {
const address = command.match(/<(.+)>/)?.[1] || '';
if (address.includes('error')) {
errorCount++;
console.log(` [Server] Error ${errorCount} - invalid sender`);
socket.write('550 5.1.8 Invalid sender address\r\n');
// State remains ready after error
} else {
socket.write('250 OK\r\n');
state = 'mail';
}
} else if (command.startsWith('RCPT TO:')) {
if (state === 'mail' || state === 'rcpt') {
const address = command.match(/<(.+)>/)?.[1] || '';
if (address.includes('error')) {
errorCount++;
console.log(` [Server] Error ${errorCount} - invalid recipient`);
socket.write('550 5.1.1 User unknown\r\n');
// State remains the same after recipient error
} else {
socket.write('250 OK\r\n');
state = 'rcpt';
}
} else {
socket.write('503 5.5.1 Bad sequence\r\n');
}
} else if (command === 'DATA') {
if (state === 'rcpt') {
socket.write('354 Start mail input\r\n');
state = 'data';
} else {
socket.write('503 5.5.1 Bad sequence\r\n');
}
} else if (command === '.') {
if (state === 'data') {
socket.write('250 OK\r\n');
state = 'ready';
}
} else if (command === 'RSET') {
console.log(` [Server] RSET - recovering from errors (${errorCount} errors so far)`);
socket.write('250 OK\r\n');
state = 'ready';
} else if (command === 'QUIT') {
console.log(` [Server] Session ended with ${errorCount} total errors`);
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('500 5.5.1 Command not recognized\r\n');
}
});
}
});
const smtpClient = createTestSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false
});
// Test recovery from various errors
const testEmails = [
{
from: 'error@example.com', // Will cause sender error
to: ['valid@example.com'],
desc: 'invalid sender'
},
{
from: 'valid@example.com',
to: ['error@example.com', 'valid@example.com'], // Mixed valid/invalid recipients
desc: 'mixed recipients'
},
{
from: 'valid@example.com',
to: ['valid@example.com'],
desc: 'valid email after errors'
}
];
for (const testEmail of testEmails) {
console.log(` Testing ${testEmail.desc}...`);
const email = new Email({
from: testEmail.from,
to: testEmail.to,
subject: `Error recovery test: ${testEmail.desc}`,
text: `Testing error state recovery with ${testEmail.desc}`
});
try {
const result = await smtpClient.sendMail(email);
console.log(` ${testEmail.desc}: Success`);
if (result.rejected && result.rejected.length > 0) {
console.log(` Rejected: ${result.rejected.length} recipients`);
}
} catch (error) {
console.log(` ${testEmail.desc}: Failed as expected - ${error.message}`);
}
}
await testServer.server.close();
})();
console.log(`\n${testId}: All ${scenarioCount} state machine scenarios tested ✓`);
});
tap.start();