feat(e2e-tests): add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions
This commit is contained in:
239
test/test.e2e.inbound-smtp.node.ts
Normal file
239
test/test.e2e.inbound-smtp.node.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import {
|
||||
connectToSmtp,
|
||||
waitForGreeting,
|
||||
sendSmtpCommand,
|
||||
performSmtpHandshake,
|
||||
createConcurrentConnections,
|
||||
createMimeMessage,
|
||||
} from './helpers/utils.js';
|
||||
|
||||
const storageMap = new Map<string, string>();
|
||||
const mockDcRouter = {
|
||||
storageManager: {
|
||||
get: async (key: string) => storageMap.get(key) || null,
|
||||
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||
},
|
||||
};
|
||||
|
||||
let server: UnifiedEmailServer;
|
||||
let bridge: RustSecurityBridge;
|
||||
let bridgeAvailable = false;
|
||||
|
||||
tap.test('setup - start server on port 10125', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
server = new UnifiedEmailServer(mockDcRouter, {
|
||||
ports: [10125],
|
||||
hostname: 'test.inbound.local',
|
||||
domains: [
|
||||
{
|
||||
domain: 'testdomain.com',
|
||||
dnsMode: 'forward',
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'catch-all',
|
||||
priority: 0,
|
||||
match: {
|
||||
recipients: '*@testdomain.com',
|
||||
},
|
||||
action: {
|
||||
type: 'process',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
bridgeAvailable = true;
|
||||
} catch (err) {
|
||||
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('EHLO and capability discovery', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||
const capabilities = await performSmtpHandshake(socket, 'test-client.local');
|
||||
|
||||
// Verify we received capabilities from the EHLO response
|
||||
expect(capabilities.length).toBeGreaterThan(0);
|
||||
|
||||
// The server hostname should be in the first capability line
|
||||
const firstLine = capabilities[0];
|
||||
expect(firstLine).toBeTruthy();
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
tap.test('send valid email - full SMTP transaction', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||
await waitForGreeting(socket, 10000);
|
||||
|
||||
// EHLO
|
||||
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||
|
||||
// MAIL FROM
|
||||
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||
|
||||
// RCPT TO
|
||||
await sendSmtpCommand(socket, 'RCPT TO:<user@testdomain.com>', '250', 10000);
|
||||
|
||||
// DATA
|
||||
await sendSmtpCommand(socket, 'DATA', '354', 10000);
|
||||
|
||||
// Send MIME message
|
||||
const mimeMessage = createMimeMessage({
|
||||
from: 'sender@example.com',
|
||||
to: 'user@testdomain.com',
|
||||
subject: 'E2E Test Email',
|
||||
text: 'This is an end-to-end test email.',
|
||||
});
|
||||
|
||||
// Send the message data followed by the terminator
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => {
|
||||
socket.removeAllListeners('data');
|
||||
reject(new Error('DATA response timeout'));
|
||||
}, 10000);
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
if (buffer.includes('250')) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
socket.write(mimeMessage + '\r\n.\r\n');
|
||||
});
|
||||
|
||||
// QUIT
|
||||
try {
|
||||
await sendSmtpCommand(socket, 'QUIT', '221', 5000);
|
||||
} catch {
|
||||
// Ignore QUIT errors
|
||||
}
|
||||
socket.destroy();
|
||||
|
||||
// Verify the email was queued for processing
|
||||
const stats = server.deliveryQueue.getStats();
|
||||
expect(stats.queueSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('multiple recipients', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||
await waitForGreeting(socket, 10000);
|
||||
|
||||
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||
await sendSmtpCommand(socket, 'RCPT TO:<user1@testdomain.com>', '250', 10000);
|
||||
await sendSmtpCommand(socket, 'RCPT TO:<user2@testdomain.com>', '250', 10000);
|
||||
|
||||
await sendSmtpCommand(socket, 'DATA', '354', 10000);
|
||||
|
||||
const mimeMessage = createMimeMessage({
|
||||
from: 'sender@example.com',
|
||||
to: 'user1@testdomain.com',
|
||||
subject: 'Multi-recipient Test',
|
||||
text: 'Testing multiple recipients.',
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => {
|
||||
socket.removeAllListeners('data');
|
||||
reject(new Error('DATA response timeout'));
|
||||
}, 10000);
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
if (buffer.includes('250')) {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
socket.write(mimeMessage + '\r\n.\r\n');
|
||||
});
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
tap.test('concurrent connections', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const sockets = await createConcurrentConnections('127.0.0.1', 10125, 3, 10000);
|
||||
expect(sockets.length).toEqual(3);
|
||||
|
||||
// Perform EHLO on each connection
|
||||
for (const socket of sockets) {
|
||||
await waitForGreeting(socket, 10000);
|
||||
await sendSmtpCommand(socket, 'EHLO concurrent-client.local', '250', 10000);
|
||||
}
|
||||
|
||||
// Close all connections
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('RSET mid-session', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||
await waitForGreeting(socket, 10000);
|
||||
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||
|
||||
// Start a transaction
|
||||
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||
|
||||
// Reset the transaction
|
||||
await sendSmtpCommand(socket, 'RSET', '250', 10000);
|
||||
|
||||
// Start a new transaction after RSET
|
||||
await sendSmtpCommand(socket, 'MAIL FROM:<other@example.com>', '250', 10000);
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop server', async () => {
|
||||
if (bridgeAvailable) {
|
||||
await server.stop();
|
||||
}
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user