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:
196
test/test.e2e.outbound-delivery.node.ts
Normal file
196
test/test.e2e.outbound-delivery.node.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import type { ISmtpPoolStatus } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import { Email } from '../ts/mail/core/classes.email.js';
|
||||
import * as net from 'net';
|
||||
|
||||
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;
|
||||
let mockSmtpServer: net.Server;
|
||||
|
||||
/**
|
||||
* Create a minimal mock SMTP server that accepts any email.
|
||||
* This avoids the IPC deadlock that occurs when the Rust SMTP client
|
||||
* sends to the same Rust process's SMTP server (the IPC stdin reader
|
||||
* blocks on the sendEmail command and can't process emailProcessingResult).
|
||||
*/
|
||||
function createMockSmtpServer(port: number): Promise<net.Server> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer((socket) => {
|
||||
socket.write('220 mock-smtp.local ESMTP MockServer\r\n');
|
||||
|
||||
let inData = false;
|
||||
let dataBuffer = '';
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
const input = chunk.toString();
|
||||
|
||||
if (inData) {
|
||||
dataBuffer += input;
|
||||
if (dataBuffer.includes('\r\n.\r\n')) {
|
||||
inData = false;
|
||||
dataBuffer = '';
|
||||
socket.write('250 2.0.0 Ok: queued\r\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Process SMTP commands line by line
|
||||
const lines = input.split('\r\n').filter((l: string) => l.length > 0);
|
||||
for (const line of lines) {
|
||||
const cmd = line.toUpperCase();
|
||||
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
|
||||
socket.write(`250-mock-smtp.local\r\n250-SIZE 10485760\r\n250 OK\r\n`);
|
||||
} else if (cmd.startsWith('MAIL FROM')) {
|
||||
socket.write('250 2.1.0 Ok\r\n');
|
||||
} else if (cmd.startsWith('RCPT TO')) {
|
||||
socket.write('250 2.1.5 Ok\r\n');
|
||||
} else if (cmd === 'DATA') {
|
||||
inData = true;
|
||||
dataBuffer = '';
|
||||
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
||||
} else if (cmd === 'QUIT') {
|
||||
socket.write('221 2.0.0 Bye\r\n');
|
||||
socket.end();
|
||||
} else if (cmd === 'RSET') {
|
||||
socket.write('250 2.0.0 Ok\r\n');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
srv.listen(port, '127.0.0.1', () => {
|
||||
resolve(srv);
|
||||
});
|
||||
|
||||
srv.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('setup - start bridge and mock SMTP server', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
server = new UnifiedEmailServer(mockDcRouter, {
|
||||
ports: [10325],
|
||||
hostname: 'test.outbound.local',
|
||||
domains: [
|
||||
{
|
||||
domain: 'outbound-test.com',
|
||||
dnsMode: 'forward',
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'catch-all',
|
||||
priority: 0,
|
||||
match: {
|
||||
recipients: '*',
|
||||
},
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start a mock SMTP server on a separate port for outbound delivery tests
|
||||
mockSmtpServer = await createMockSmtpServer(10326);
|
||||
});
|
||||
|
||||
tap.test('send email to mock SMTP receiver', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@outbound-test.com',
|
||||
to: 'recipient@outbound-test.com',
|
||||
subject: 'Outbound E2E Test',
|
||||
text: 'Testing outbound delivery to the mock SMTP server.',
|
||||
});
|
||||
|
||||
// Send to the mock SMTP server (port 10326), not the Rust SMTP server (port 10325)
|
||||
const result = await server.sendOutboundEmail('127.0.0.1', 10326, email);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.accepted).toBeTruthy();
|
||||
expect(result.accepted.length).toBeGreaterThan(0);
|
||||
expect(result.response).toBeTruthy();
|
||||
// Rust SMTP client returns enhanced status code without the 250 prefix
|
||||
expect(result.response).toInclude('2.0.0');
|
||||
});
|
||||
|
||||
tap.test('send email - connection refused', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@outbound-test.com',
|
||||
to: 'recipient@outbound-test.com',
|
||||
subject: 'Connection Refused Test',
|
||||
text: 'This should fail — no server at port 59888.',
|
||||
});
|
||||
|
||||
try {
|
||||
await server.sendOutboundEmail('127.0.0.1', 59888, email);
|
||||
throw new Error('Expected sendOutboundEmail to fail on connection refused');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeTruthy();
|
||||
expect(err.message.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('SMTP pool status and cleanup', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const status: ISmtpPoolStatus = await bridge.getSmtpPoolStatus();
|
||||
expect(status).toBeTruthy();
|
||||
expect(status.pools).toBeTruthy();
|
||||
expect(typeof status.pools).toEqual('object');
|
||||
|
||||
// Close all pools
|
||||
await bridge.closeSmtpPool();
|
||||
|
||||
// Verify pools are empty
|
||||
const statusAfter = await bridge.getSmtpPoolStatus();
|
||||
const poolKeys = Object.keys(statusAfter.pools);
|
||||
expect(poolKeys.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop server and mock', async () => {
|
||||
if (mockSmtpServer) {
|
||||
await new Promise<void>((resolve) => mockSmtpServer.close(() => resolve()));
|
||||
}
|
||||
if (bridgeAvailable) {
|
||||
await server.stop();
|
||||
}
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user