2025-05-23 19:09:30 +00:00
|
|
|
import * as plugins from '@git.zone/tstest/tapbundle';
|
|
|
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
2025-05-23 19:03:44 +00:00
|
|
|
import * as net from 'net';
|
2025-05-23 21:20:39 +00:00
|
|
|
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
2025-05-23 19:03:44 +00:00
|
|
|
|
|
|
|
const TEST_PORT = 2525;
|
|
|
|
|
2025-05-24 00:23:35 +00:00
|
|
|
let testServer;
|
|
|
|
|
2025-05-23 19:03:44 +00:00
|
|
|
interface DnsTestResult {
|
|
|
|
scenario: string;
|
|
|
|
domain: string;
|
|
|
|
expectedBehavior: string;
|
|
|
|
mailFromSuccess: boolean;
|
|
|
|
rcptToSuccess: boolean;
|
|
|
|
mailFromResponse: string;
|
|
|
|
rcptToResponse: string;
|
|
|
|
handledGracefully: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
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));
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('REL-05: DNS resolution failure handling - Non-existent domains', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
try {
|
|
|
|
const socket = net.createConnection({
|
|
|
|
host: 'localhost',
|
|
|
|
port: TEST_PORT,
|
|
|
|
timeout: 30000
|
|
|
|
});
|
|
|
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
socket.once('connect', resolve);
|
|
|
|
socket.once('error', reject);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Read greeting
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
socket.once('data', () => resolve());
|
|
|
|
});
|
|
|
|
|
|
|
|
// Send EHLO
|
|
|
|
socket.write('EHLO dns-test\r\n');
|
|
|
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
let data = '';
|
|
|
|
const handleData = (chunk: Buffer) => {
|
|
|
|
data += chunk.toString();
|
|
|
|
if (data.includes('250 ') && !data.includes('250-')) {
|
|
|
|
socket.removeListener('data', handleData);
|
|
|
|
resolve();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
socket.on('data', handleData);
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log('Testing DNS resolution for non-existent domains...');
|
|
|
|
|
|
|
|
// Test 1: Non-existent domain in MAIL FROM
|
|
|
|
socket.write('MAIL FROM:<sender@non-existent-domain-12345.invalid>\r\n');
|
|
|
|
|
|
|
|
const mailResponse = await new Promise<string>((resolve) => {
|
|
|
|
socket.once('data', (chunk) => {
|
|
|
|
resolve(chunk.toString());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log(' MAIL FROM response:', mailResponse.trim());
|
|
|
|
|
|
|
|
// Server should either accept (and defer later) or reject immediately
|
|
|
|
const mailFromHandled = mailResponse.includes('250') ||
|
|
|
|
mailResponse.includes('450') ||
|
|
|
|
mailResponse.includes('550');
|
2025-05-23 21:20:39 +00:00
|
|
|
expect(mailFromHandled).toEqual(true);
|
2025-05-23 19:03:44 +00:00
|
|
|
|
|
|
|
// Reset if needed
|
|
|
|
if (mailResponse.includes('250')) {
|
|
|
|
socket.write('RSET\r\n');
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
socket.once('data', () => resolve());
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Test 2: Non-existent domain in RCPT TO
|
|
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
|
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
socket.once('data', (chunk) => {
|
|
|
|
const response = chunk.toString();
|
|
|
|
expect(response).toInclude('250');
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
socket.write('RCPT TO:<recipient@non-existent-domain-xyz.invalid>\r\n');
|
|
|
|
|
|
|
|
const rcptResponse = await new Promise<string>((resolve) => {
|
|
|
|
socket.once('data', (chunk) => {
|
|
|
|
resolve(chunk.toString());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log(' RCPT TO response:', rcptResponse.trim());
|
|
|
|
|
|
|
|
// Server should reject or defer non-existent domains
|
|
|
|
const rcptToHandled = rcptResponse.includes('450') || // Temporary failure
|
|
|
|
rcptResponse.includes('550') || // Permanent failure
|
|
|
|
rcptResponse.includes('553'); // Address error
|
2025-05-23 21:20:39 +00:00
|
|
|
expect(rcptToHandled).toEqual(true);
|
2025-05-23 19:03:44 +00:00
|
|
|
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
socket.end();
|
|
|
|
done.resolve();
|
|
|
|
} catch (error) {
|
|
|
|
done.reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('REL-05: DNS resolution failure handling - Malformed domains', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
try {
|
|
|
|
const socket = net.createConnection({
|
|
|
|
host: 'localhost',
|
|
|
|
port: TEST_PORT,
|
|
|
|
timeout: 30000
|
|
|
|
});
|
|
|
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
socket.once('connect', resolve);
|
|
|
|
socket.once('error', reject);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Read greeting
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
socket.once('data', () => resolve());
|
|
|
|
});
|
|
|
|
|
|
|
|
// Send EHLO
|
|
|
|
socket.write('EHLO malformed-test\r\n');
|
|
|
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
let data = '';
|
|
|
|
const handleData = (chunk: Buffer) => {
|
|
|
|
data += chunk.toString();
|
|
|
|
if (data.includes('250 ') && !data.includes('250-')) {
|
|
|
|
socket.removeListener('data', handleData);
|
|
|
|
resolve();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
socket.on('data', handleData);
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log('\nTesting malformed domain handling...');
|
|
|
|
|
|
|
|
const malformedDomains = [
|
|
|
|
'malformed..domain..test',
|
|
|
|
'invalid-.domain.com',
|
|
|
|
'domain.with.spaces .com',
|
|
|
|
'.leading-dot.com',
|
|
|
|
'trailing-dot.com.',
|
|
|
|
'domain@with@at.com',
|
|
|
|
'a'.repeat(255) + '.toolong.com' // Domain too long
|
|
|
|
];
|
|
|
|
|
|
|
|
for (const domain of malformedDomains) {
|
|
|
|
console.log(` Testing: ${domain.substring(0, 50)}${domain.length > 50 ? '...' : ''}`);
|
|
|
|
|
|
|
|
socket.write(`MAIL FROM:<test@${domain}>\r\n`);
|
|
|
|
|
|
|
|
const response = await new Promise<string>((resolve) => {
|
|
|
|
socket.once('data', (chunk) => {
|
|
|
|
resolve(chunk.toString());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
// Server should reject malformed domains
|
|
|
|
const properlyHandled = response.includes('501') || // Syntax error
|
|
|
|
response.includes('550') || // Rejected
|
|
|
|
response.includes('553'); // Address error
|
|
|
|
|
|
|
|
console.log(` Response: ${response.trim().substring(0, 50)}`);
|
2025-05-23 21:20:39 +00:00
|
|
|
expect(properlyHandled).toEqual(true);
|
2025-05-23 19:03:44 +00:00
|
|
|
|
|
|
|
// Reset if needed
|
|
|
|
if (!response.includes('5')) {
|
|
|
|
socket.write('RSET\r\n');
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
socket.once('data', () => resolve());
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
socket.end();
|
|
|
|
done.resolve();
|
|
|
|
} catch (error) {
|
|
|
|
done.reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('REL-05: DNS resolution failure handling - Special cases', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
try {
|
|
|
|
const socket = net.createConnection({
|
|
|
|
host: 'localhost',
|
|
|
|
port: TEST_PORT,
|
|
|
|
timeout: 30000
|
|
|
|
});
|
|
|
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
socket.once('connect', resolve);
|
|
|
|
socket.once('error', reject);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Read greeting
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
socket.once('data', () => resolve());
|
|
|
|
});
|
|
|
|
|
|
|
|
// Send EHLO
|
|
|
|
socket.write('EHLO special-test\r\n');
|
|
|
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
let data = '';
|
|
|
|
const handleData = (chunk: Buffer) => {
|
|
|
|
data += chunk.toString();
|
|
|
|
if (data.includes('250 ') && !data.includes('250-')) {
|
|
|
|
socket.removeListener('data', handleData);
|
|
|
|
resolve();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
socket.on('data', handleData);
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log('\nTesting special DNS cases...');
|
|
|
|
|
|
|
|
// Test 1: Localhost (should work)
|
|
|
|
socket.write('MAIL FROM:<sender@localhost>\r\n');
|
|
|
|
|
|
|
|
const localhostResponse = await new Promise<string>((resolve) => {
|
|
|
|
socket.once('data', (chunk) => {
|
|
|
|
resolve(chunk.toString());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log(' Localhost response:', localhostResponse.trim());
|
|
|
|
expect(localhostResponse).toInclude('250');
|
|
|
|
|
|
|
|
socket.write('RSET\r\n');
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
socket.once('data', () => resolve());
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test 2: IP address (should work)
|
|
|
|
socket.write('MAIL FROM:<sender@[127.0.0.1]>\r\n');
|
|
|
|
|
|
|
|
const ipResponse = await new Promise<string>((resolve) => {
|
|
|
|
socket.once('data', (chunk) => {
|
|
|
|
resolve(chunk.toString());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log(' IP address response:', ipResponse.trim());
|
|
|
|
const ipHandled = ipResponse.includes('250') || ipResponse.includes('501');
|
2025-05-23 21:20:39 +00:00
|
|
|
expect(ipHandled).toEqual(true);
|
2025-05-23 19:03:44 +00:00
|
|
|
|
|
|
|
socket.write('RSET\r\n');
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
socket.once('data', () => resolve());
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test 3: Empty domain
|
|
|
|
socket.write('MAIL FROM:<sender@>\r\n');
|
|
|
|
|
|
|
|
const emptyResponse = await new Promise<string>((resolve) => {
|
|
|
|
socket.once('data', (chunk) => {
|
|
|
|
resolve(chunk.toString());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log(' Empty domain response:', emptyResponse.trim());
|
|
|
|
expect(emptyResponse).toMatch(/50[1-3]/); // Should reject
|
|
|
|
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
socket.end();
|
|
|
|
done.resolve();
|
|
|
|
} catch (error) {
|
|
|
|
done.reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('REL-05: DNS resolution failure handling - Mixed valid/invalid recipients', async (tools) => {
|
|
|
|
const done = tools.defer();
|
|
|
|
|
|
|
|
try {
|
|
|
|
const socket = net.createConnection({
|
|
|
|
host: 'localhost',
|
|
|
|
port: TEST_PORT,
|
|
|
|
timeout: 30000
|
|
|
|
});
|
|
|
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
socket.once('connect', resolve);
|
|
|
|
socket.once('error', reject);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Read greeting
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
socket.once('data', () => resolve());
|
|
|
|
});
|
|
|
|
|
|
|
|
// Send EHLO
|
|
|
|
socket.write('EHLO mixed-test\r\n');
|
|
|
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
let data = '';
|
|
|
|
const handleData = (chunk: Buffer) => {
|
|
|
|
data += chunk.toString();
|
|
|
|
if (data.includes('250 ') && !data.includes('250-')) {
|
|
|
|
socket.removeListener('data', handleData);
|
|
|
|
resolve();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
socket.on('data', handleData);
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log('\nTesting mixed valid/invalid recipients...');
|
|
|
|
|
|
|
|
// Start transaction
|
|
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
|
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
socket.once('data', (chunk) => {
|
|
|
|
const response = chunk.toString();
|
|
|
|
expect(response).toInclude('250');
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
// Add valid recipient
|
|
|
|
socket.write('RCPT TO:<valid@example.com>\r\n');
|
|
|
|
|
|
|
|
const validRcptResponse = await new Promise<string>((resolve) => {
|
|
|
|
socket.once('data', (chunk) => {
|
|
|
|
resolve(chunk.toString());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log(' Valid recipient:', validRcptResponse.trim());
|
|
|
|
expect(validRcptResponse).toInclude('250');
|
|
|
|
|
|
|
|
// Add invalid recipient
|
|
|
|
socket.write('RCPT TO:<invalid@non-existent-domain-abc.invalid>\r\n');
|
|
|
|
|
|
|
|
const invalidRcptResponse = await new Promise<string>((resolve) => {
|
|
|
|
socket.once('data', (chunk) => {
|
|
|
|
resolve(chunk.toString());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log(' Invalid recipient:', invalidRcptResponse.trim());
|
|
|
|
|
|
|
|
// Server should reject invalid domain but keep transaction alive
|
|
|
|
const invalidHandled = invalidRcptResponse.includes('450') ||
|
|
|
|
invalidRcptResponse.includes('550') ||
|
|
|
|
invalidRcptResponse.includes('553');
|
2025-05-23 21:20:39 +00:00
|
|
|
expect(invalidHandled).toEqual(true);
|
2025-05-23 19:03:44 +00:00
|
|
|
|
|
|
|
// Try to send data (should work if at least one valid recipient)
|
|
|
|
socket.write('DATA\r\n');
|
|
|
|
|
|
|
|
const dataResponse = await new Promise<string>((resolve) => {
|
|
|
|
socket.once('data', (chunk) => {
|
|
|
|
resolve(chunk.toString());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
if (dataResponse.includes('354')) {
|
|
|
|
socket.write('Subject: Mixed recipient test\r\n\r\nTest\r\n.\r\n');
|
|
|
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
socket.once('data', () => resolve());
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log(' Message accepted with valid recipient');
|
|
|
|
} else {
|
|
|
|
console.log(' Server rejected DATA (acceptable behavior)');
|
|
|
|
}
|
|
|
|
|
|
|
|
socket.write('QUIT\r\n');
|
|
|
|
socket.end();
|
|
|
|
done.resolve();
|
|
|
|
} catch (error) {
|
|
|
|
done.reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
tap.test('cleanup server', async () => {
|
2025-05-24 00:23:35 +00:00
|
|
|
await stopTestServer(testServer);
|
2025-05-23 19:03:44 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
tap.start();
|