2025-05-23 19:09:30 +00:00
|
|
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
2025-05-23 19:03:44 +00:00
|
|
|
import * as net from 'net';
|
|
|
|
import * as path from 'path';
|
2025-05-23 21:20:39 +00:00
|
|
|
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
2025-05-23 19:03:44 +00:00
|
|
|
|
|
|
|
// Test configuration
|
|
|
|
const TEST_PORT = 2525;
|
2025-05-24 00:23:35 +00:00
|
|
|
|
|
|
|
let testServer;
|
2025-05-23 19:03:44 +00:00
|
|
|
const TEST_TIMEOUT = 15000;
|
|
|
|
|
|
|
|
// Setup
|
|
|
|
tap.test('prepare server', async () => {
|
2025-05-24 00:23:35 +00:00
|
|
|
testServer = await startTestServer({ port: TEST_PORT });
|
2025-05-23 19:03:44 +00:00
|
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test: SIZE extension advertised in EHLO
|
|
|
|
tap.test('SIZE Extension - should advertise SIZE in EHLO response', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
const socket = net.createConnection({
|
|
|
|
host: 'localhost',
|
|
|
|
port: TEST_PORT,
|
|
|
|
timeout: TEST_TIMEOUT
|
|
|
|
});
|
|
|
|
|
|
|
|
let receivedData = '';
|
|
|
|
let currentStep = 'connecting';
|
|
|
|
let sizeSupported = false;
|
|
|
|
let maxMessageSize: number | null = null;
|
|
|
|
|
|
|
|
socket.on('data', (data) => {
|
|
|
|
receivedData += data.toString();
|
|
|
|
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
|
|
currentStep = 'ehlo';
|
|
|
|
socket.write('EHLO test.example.com\r\n');
|
|
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
|
|
// Check if SIZE extension is advertised
|
|
|
|
if (receivedData.includes('SIZE')) {
|
|
|
|
sizeSupported = true;
|
|
|
|
|
|
|
|
// Extract maximum message size if specified
|
|
|
|
const sizeMatch = receivedData.match(/SIZE\s+(\d+)/);
|
|
|
|
if (sizeMatch) {
|
|
|
|
maxMessageSize = parseInt(sizeMatch[1]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
setTimeout(() => {
|
|
|
|
socket.destroy();
|
2025-05-23 21:20:39 +00:00
|
|
|
expect(sizeSupported).toEqual(true);
|
2025-05-23 19:03:44 +00:00
|
|
|
if (maxMessageSize !== null) {
|
|
|
|
expect(maxMessageSize).toBeGreaterThan(0);
|
|
|
|
}
|
|
|
|
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;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test: MAIL FROM with SIZE parameter
|
|
|
|
tap.test('SIZE Extension - should accept MAIL FROM with SIZE parameter', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
const socket = net.createConnection({
|
|
|
|
host: 'localhost',
|
|
|
|
port: TEST_PORT,
|
|
|
|
timeout: TEST_TIMEOUT
|
|
|
|
});
|
|
|
|
|
|
|
|
let receivedData = '';
|
|
|
|
let currentStep = 'connecting';
|
|
|
|
const messageSize = 1000;
|
|
|
|
|
|
|
|
socket.on('data', (data) => {
|
|
|
|
receivedData += data.toString();
|
|
|
|
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
|
|
currentStep = 'ehlo';
|
|
|
|
socket.write('EHLO test.example.com\r\n');
|
|
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
|
|
currentStep = 'mail_from_size';
|
|
|
|
socket.write(`MAIL FROM:<sender@example.com> SIZE=${messageSize}\r\n`);
|
|
|
|
} else if (currentStep === 'mail_from_size' && receivedData.includes('250')) {
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
setTimeout(() => {
|
|
|
|
socket.destroy();
|
|
|
|
expect(receivedData).toInclude('250 OK');
|
|
|
|
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;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test: SIZE parameter with various sizes
|
|
|
|
tap.test('SIZE Extension - should handle different message sizes', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
const socket = net.createConnection({
|
|
|
|
host: 'localhost',
|
|
|
|
port: TEST_PORT,
|
|
|
|
timeout: TEST_TIMEOUT
|
|
|
|
});
|
|
|
|
|
|
|
|
let receivedData = '';
|
|
|
|
let currentStep = 'connecting';
|
|
|
|
const testSizes = [1000, 10000, 100000, 1000000]; // 1KB, 10KB, 100KB, 1MB
|
|
|
|
let currentSizeIndex = 0;
|
|
|
|
const sizeResults: Array<{ size: number; accepted: boolean; response: string }> = [];
|
|
|
|
|
|
|
|
const testNextSize = () => {
|
|
|
|
if (currentSizeIndex < testSizes.length) {
|
|
|
|
receivedData = ''; // Clear buffer
|
|
|
|
const size = testSizes[currentSizeIndex];
|
|
|
|
socket.write(`MAIL FROM:<sender@example.com> SIZE=${size}\r\n`);
|
|
|
|
} else {
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
setTimeout(() => {
|
|
|
|
socket.destroy();
|
|
|
|
|
|
|
|
// At least some sizes should be accepted
|
|
|
|
const acceptedCount = sizeResults.filter(r => r.accepted).length;
|
|
|
|
expect(acceptedCount).toBeGreaterThan(0);
|
|
|
|
|
|
|
|
// Verify larger sizes may be rejected
|
|
|
|
const largeRejected = sizeResults
|
|
|
|
.filter(r => r.size >= 1000000 && !r.accepted)
|
|
|
|
.length;
|
|
|
|
expect(largeRejected + acceptedCount).toEqual(sizeResults.length);
|
|
|
|
|
|
|
|
done.resolve();
|
|
|
|
}, 100);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
socket.on('data', (data) => {
|
|
|
|
receivedData += data.toString();
|
|
|
|
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
|
|
currentStep = 'ehlo';
|
|
|
|
socket.write('EHLO test.example.com\r\n');
|
|
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
|
|
currentStep = 'mail_from_sizes';
|
|
|
|
testNextSize();
|
|
|
|
} else if (currentStep === 'mail_from_sizes') {
|
|
|
|
if (receivedData.includes('250')) {
|
|
|
|
// Size accepted
|
|
|
|
sizeResults.push({
|
|
|
|
size: testSizes[currentSizeIndex],
|
|
|
|
accepted: true,
|
|
|
|
response: receivedData.trim()
|
|
|
|
});
|
|
|
|
|
|
|
|
socket.write('RSET\r\n');
|
|
|
|
currentSizeIndex++;
|
|
|
|
currentStep = 'rset';
|
|
|
|
} else if (receivedData.includes('552') || receivedData.includes('5')) {
|
|
|
|
// Size rejected
|
|
|
|
sizeResults.push({
|
|
|
|
size: testSizes[currentSizeIndex],
|
|
|
|
accepted: false,
|
|
|
|
response: receivedData.trim()
|
|
|
|
});
|
|
|
|
|
|
|
|
socket.write('RSET\r\n');
|
|
|
|
currentSizeIndex++;
|
|
|
|
currentStep = 'rset';
|
|
|
|
}
|
|
|
|
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
|
|
currentStep = 'mail_from_sizes';
|
|
|
|
testNextSize();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
socket.on('error', (error) => {
|
|
|
|
done.reject(error);
|
|
|
|
});
|
|
|
|
|
|
|
|
socket.on('timeout', () => {
|
|
|
|
socket.destroy();
|
|
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
|
|
});
|
|
|
|
|
|
|
|
await done.promise;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test: SIZE parameter exceeding limit
|
|
|
|
tap.test('SIZE Extension - should reject SIZE exceeding server limit', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
const socket = net.createConnection({
|
|
|
|
host: 'localhost',
|
|
|
|
port: TEST_PORT,
|
|
|
|
timeout: TEST_TIMEOUT
|
|
|
|
});
|
|
|
|
|
|
|
|
let receivedData = '';
|
|
|
|
let currentStep = 'connecting';
|
|
|
|
let maxSize: number | null = null;
|
|
|
|
|
|
|
|
socket.on('data', (data) => {
|
|
|
|
receivedData += data.toString();
|
|
|
|
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
|
|
currentStep = 'ehlo';
|
|
|
|
socket.write('EHLO test.example.com\r\n');
|
|
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
|
|
// Extract max size if advertised
|
|
|
|
const sizeMatch = receivedData.match(/SIZE\s+(\d+)/);
|
|
|
|
if (sizeMatch) {
|
|
|
|
maxSize = parseInt(sizeMatch[1]);
|
|
|
|
}
|
|
|
|
|
|
|
|
currentStep = 'mail_from_oversized';
|
|
|
|
// Try to send a message larger than any reasonable limit
|
|
|
|
const oversizedValue = maxSize ? maxSize + 1 : 100000000; // 100MB or maxSize+1
|
|
|
|
socket.write(`MAIL FROM:<sender@example.com> SIZE=${oversizedValue}\r\n`);
|
|
|
|
} else if (currentStep === 'mail_from_oversized') {
|
|
|
|
if (receivedData.includes('552') || receivedData.includes('5')) {
|
|
|
|
// Size limit exceeded - expected
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
setTimeout(() => {
|
|
|
|
socket.destroy();
|
|
|
|
expect(receivedData).toMatch(/552|5\d{2}/);
|
|
|
|
done.resolve();
|
|
|
|
}, 100);
|
|
|
|
} else if (receivedData.includes('250')) {
|
|
|
|
// If accepted, server has very high or no limit
|
|
|
|
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;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test: SIZE=0 (empty message)
|
|
|
|
tap.test('SIZE Extension - should handle SIZE=0', 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';
|
|
|
|
socket.write('EHLO test.example.com\r\n');
|
|
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
|
|
currentStep = 'mail_from_zero_size';
|
|
|
|
socket.write('MAIL FROM:<sender@example.com> SIZE=0\r\n');
|
|
|
|
} else if (currentStep === 'mail_from_zero_size' && receivedData.includes('250')) {
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
setTimeout(() => {
|
|
|
|
socket.destroy();
|
|
|
|
expect(receivedData).toInclude('250');
|
|
|
|
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;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test: Invalid SIZE parameter
|
|
|
|
tap.test('SIZE Extension - should reject invalid SIZE values', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
const socket = net.createConnection({
|
|
|
|
host: 'localhost',
|
|
|
|
port: TEST_PORT,
|
|
|
|
timeout: TEST_TIMEOUT
|
|
|
|
});
|
|
|
|
|
|
|
|
let receivedData = '';
|
|
|
|
let currentStep = 'connecting';
|
|
|
|
const invalidSizes = ['abc', '-1', '1.5', '']; // Invalid size values
|
|
|
|
let currentIndex = 0;
|
|
|
|
const results: Array<{ value: string; rejected: boolean }> = [];
|
|
|
|
|
|
|
|
const testNextInvalidSize = () => {
|
|
|
|
if (currentIndex < invalidSizes.length) {
|
|
|
|
receivedData = ''; // Clear buffer
|
|
|
|
const invalidSize = invalidSizes[currentIndex];
|
|
|
|
socket.write(`MAIL FROM:<sender@example.com> SIZE=${invalidSize}\r\n`);
|
|
|
|
} else {
|
|
|
|
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
setTimeout(() => {
|
|
|
|
socket.destroy();
|
|
|
|
|
|
|
|
// This server accepts invalid SIZE values without strict validation
|
|
|
|
// This is permissive but not necessarily incorrect
|
|
|
|
// Just verify we got responses for all test cases
|
|
|
|
expect(results.length).toEqual(invalidSizes.length);
|
|
|
|
|
|
|
|
done.resolve();
|
|
|
|
}, 100);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
socket.on('data', (data) => {
|
|
|
|
receivedData += data.toString();
|
|
|
|
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
|
|
currentStep = 'ehlo';
|
|
|
|
socket.write('EHLO test.example.com\r\n');
|
|
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
|
|
currentStep = 'invalid_sizes';
|
|
|
|
testNextInvalidSize();
|
|
|
|
} else if (currentStep === 'invalid_sizes' && currentIndex < invalidSizes.length) {
|
|
|
|
if (receivedData.includes('250')) {
|
|
|
|
// This server accepts invalid size values
|
|
|
|
results.push({
|
|
|
|
value: invalidSizes[currentIndex],
|
|
|
|
rejected: false
|
|
|
|
});
|
|
|
|
} else if (receivedData.includes('501') || receivedData.includes('552')) {
|
|
|
|
// Invalid parameter - proper validation
|
|
|
|
results.push({
|
|
|
|
value: invalidSizes[currentIndex],
|
|
|
|
rejected: true
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
socket.write('RSET\r\n');
|
|
|
|
currentIndex++;
|
|
|
|
currentStep = 'rset';
|
|
|
|
} else if (currentStep === 'rset' && receivedData.includes('250')) {
|
|
|
|
currentStep = 'invalid_sizes';
|
|
|
|
testNextInvalidSize();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
socket.on('error', (error) => {
|
|
|
|
done.reject(error);
|
|
|
|
});
|
|
|
|
|
|
|
|
socket.on('timeout', () => {
|
|
|
|
socket.destroy();
|
|
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
|
|
});
|
|
|
|
|
|
|
|
await done.promise;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test: SIZE with actual message data
|
|
|
|
tap.test('SIZE Extension - should enforce SIZE during DATA phase', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
const socket = net.createConnection({
|
|
|
|
host: 'localhost',
|
|
|
|
port: TEST_PORT,
|
|
|
|
timeout: TEST_TIMEOUT
|
|
|
|
});
|
|
|
|
|
|
|
|
let receivedData = '';
|
|
|
|
let currentStep = 'connecting';
|
|
|
|
const declaredSize = 100; // Declare 100 bytes
|
|
|
|
const actualMessage = 'X'.repeat(200); // Send 200 bytes (more than declared)
|
|
|
|
|
|
|
|
socket.on('data', (data) => {
|
|
|
|
receivedData += data.toString();
|
|
|
|
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
|
|
currentStep = 'ehlo';
|
|
|
|
socket.write('EHLO test.example.com\r\n');
|
|
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
|
|
currentStep = 'mail_from';
|
|
|
|
socket.write(`MAIL FROM:<sender@example.com> SIZE=${declaredSize}\r\n`);
|
|
|
|
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
|
|
currentStep = 'rcpt_to';
|
|
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
|
|
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
|
|
currentStep = 'data';
|
|
|
|
socket.write('DATA\r\n');
|
|
|
|
} else if (currentStep === 'data' && receivedData.includes('354')) {
|
|
|
|
currentStep = 'message';
|
|
|
|
// Send message larger than declared size
|
|
|
|
socket.write(`Subject: Size Test\r\n\r\n${actualMessage}\r\n.\r\n`);
|
|
|
|
} else if (currentStep === 'message') {
|
|
|
|
// Server may accept or reject based on enforcement
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
setTimeout(() => {
|
|
|
|
socket.destroy();
|
|
|
|
// Either accepted (250) or rejected (552)
|
|
|
|
expect(receivedData).toMatch(/250|552/);
|
|
|
|
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;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Teardown
|
|
|
|
tap.test('cleanup server', async () => {
|
2025-05-24 00:23:35 +00:00
|
|
|
await stopTestServer(testServer);
|
2025-05-23 19:03:44 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
// Start the test
|
|
|
|
tap.start();
|