334 lines
9.5 KiB
TypeScript
334 lines
9.5 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 = 30000;
|
|
|
|
tap.test('prepare server', async () => {
|
|
testServer = await startTestServer({ port: TEST_PORT });
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
});
|
|
|
|
tap.test('Command Pipelining - should advertise PIPELINING in EHLO response', 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
|
|
const banner = await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
expect(banner).toInclude('220');
|
|
|
|
// Send EHLO
|
|
socket.write('EHLO testhost\r\n');
|
|
|
|
const ehloResponse = 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);
|
|
});
|
|
|
|
console.log('EHLO response:', ehloResponse);
|
|
|
|
// Check if PIPELINING is advertised
|
|
const pipeliningAdvertised = ehloResponse.includes('250-PIPELINING') || ehloResponse.includes('250 PIPELINING');
|
|
console.log('PIPELINING advertised:', pipeliningAdvertised);
|
|
|
|
// Clean up
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
|
|
// Note: PIPELINING is optional per RFC 2920
|
|
expect(ehloResponse).toInclude('250');
|
|
|
|
} finally {
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('Command Pipelining - should handle pipelined MAIL FROM and RCPT TO', 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 pipelined commands (all at once)
|
|
const pipelinedCommands =
|
|
'MAIL FROM:<sender@example.com>\r\n' +
|
|
'RCPT TO:<recipient@example.com>\r\n';
|
|
|
|
console.log('Sending pipelined commands...');
|
|
socket.write(pipelinedCommands);
|
|
|
|
// Collect responses
|
|
const responses = await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
let responseCount = 0;
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
const lines = data.split('\r\n').filter(line => line.trim());
|
|
|
|
// Count responses that look like complete SMTP responses
|
|
const completeResponses = lines.filter(line => /^[0-9]{3}(\s|-)/.test(line));
|
|
|
|
// We expect 2 responses (one for MAIL FROM, one for RCPT TO)
|
|
if (completeResponses.length >= 2) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
|
|
// Timeout if we don't get responses
|
|
setTimeout(() => {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}, 5000);
|
|
});
|
|
|
|
console.log('Pipelined command responses:', responses);
|
|
|
|
// Parse responses
|
|
const responseLines = responses.split('\r\n').filter(line => line.trim());
|
|
const mailFromResponse = responseLines.find(line => line.match(/^250.*/) && responseLines.indexOf(line) === 0);
|
|
const rcptToResponse = responseLines.find(line => line.match(/^250.*/) && responseLines.indexOf(line) === 1);
|
|
|
|
// Both commands should succeed
|
|
expect(mailFromResponse).toBeDefined();
|
|
expect(rcptToResponse).toBeDefined();
|
|
|
|
// Clean up
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
|
|
} finally {
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('Command Pipelining - should handle pipelined commands with DATA', 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 pipelined MAIL FROM, RCPT TO, and DATA commands
|
|
const pipelinedCommands =
|
|
'MAIL FROM:<sender@example.com>\r\n' +
|
|
'RCPT TO:<recipient@example.com>\r\n' +
|
|
'DATA\r\n';
|
|
|
|
console.log('Sending pipelined commands with DATA...');
|
|
socket.write(pipelinedCommands);
|
|
|
|
// Collect responses
|
|
const responses = await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
|
|
// Look for the DATA prompt (354)
|
|
if (data.includes('354')) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
|
|
setTimeout(() => {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}, 5000);
|
|
});
|
|
|
|
console.log('Responses including DATA:', responses);
|
|
|
|
// Should get 250 for MAIL FROM, 250 for RCPT TO, and 354 for DATA
|
|
expect(responses).toInclude('250'); // MAIL FROM OK
|
|
expect(responses).toInclude('354'); // Start mail input
|
|
|
|
// Send email content
|
|
const emailContent = 'Subject: Pipelining Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\nTest email with pipelining.\r\n.\r\n';
|
|
socket.write(emailContent);
|
|
|
|
// Get final response
|
|
const finalResponse = await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
console.log('Final response:', finalResponse);
|
|
expect(finalResponse).toInclude('250');
|
|
|
|
// Clean up
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
|
|
} finally {
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('Command Pipelining - should handle pipelined NOOP 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 pipelined NOOP commands
|
|
const pipelinedNoops =
|
|
'NOOP\r\n' +
|
|
'NOOP\r\n' +
|
|
'NOOP\r\n';
|
|
|
|
console.log('Sending pipelined NOOP commands...');
|
|
socket.write(pipelinedNoops);
|
|
|
|
// Collect responses
|
|
const responses = await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
const responseCount = (data.match(/^250.*OK/gm) || []).length;
|
|
|
|
// We expect 3 NOOP responses
|
|
if (responseCount >= 3) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
|
|
setTimeout(() => {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}, 5000);
|
|
});
|
|
|
|
console.log('NOOP responses:', responses);
|
|
|
|
// Count OK responses
|
|
const okResponses = (responses.match(/^250.*OK/gm) || []).length;
|
|
expect(okResponses).toBeGreaterThanOrEqual(3);
|
|
|
|
// Clean up
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
|
|
} finally {
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('cleanup server', async () => {
|
|
await stopTestServer(testServer);
|
|
});
|
|
|
|
export default tap.start(); |