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.routing-actions.node.ts
Normal file
239
test/test.e2e.routing-actions.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 { Email } from '../ts/mail/core/classes.email.js';
|
||||
import { SmtpState } from '../ts/mail/delivery/interfaces.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;
|
||||
|
||||
/**
|
||||
* Build a minimal SMTP session object for processEmailByMode().
|
||||
*/
|
||||
function buildSession(email: Email): any {
|
||||
return {
|
||||
id: `test-${Date.now()}-${Math.random().toString(36).substring(2)}`,
|
||||
state: SmtpState.FINISHED,
|
||||
mailFrom: email.from,
|
||||
rcptTo: email.to,
|
||||
emailData: '',
|
||||
useTLS: false,
|
||||
connectionEnded: false,
|
||||
remoteAddress: '127.0.0.1',
|
||||
clientHostname: 'test-client.local',
|
||||
secure: false,
|
||||
authenticated: false,
|
||||
envelope: {
|
||||
mailFrom: { address: email.from, args: {} },
|
||||
rcptTo: email.to.map((addr: string) => ({ address: addr, args: {} })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
tap.test('setup - start server with routing rules on port 10225', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
bridge = RustSecurityBridge.getInstance();
|
||||
|
||||
server = new UnifiedEmailServer(mockDcRouter, {
|
||||
ports: [10225],
|
||||
hostname: 'test.routing.local',
|
||||
domains: [
|
||||
{ domain: 'process.com', dnsMode: 'forward' },
|
||||
{ domain: 'local.com', dnsMode: 'forward' },
|
||||
{ domain: 'external.com', dnsMode: 'forward' },
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'reject-route',
|
||||
priority: 40,
|
||||
match: {
|
||||
senders: '*@spammer.com',
|
||||
},
|
||||
action: {
|
||||
type: 'reject',
|
||||
reject: {
|
||||
code: 550,
|
||||
message: 'Spam rejected',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'process-route',
|
||||
priority: 30,
|
||||
match: {
|
||||
recipients: '*@process.com',
|
||||
},
|
||||
action: {
|
||||
type: 'process',
|
||||
process: {
|
||||
scan: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'deliver-route',
|
||||
priority: 20,
|
||||
match: {
|
||||
recipients: '*@local.com',
|
||||
},
|
||||
action: {
|
||||
type: 'deliver',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'forward-route',
|
||||
priority: 10,
|
||||
match: {
|
||||
recipients: '*@external.com',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: '127.0.0.1',
|
||||
port: 59999, // No server listening — expected failure
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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('process action - queues email for processing', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'user@process.com',
|
||||
subject: 'Process test',
|
||||
text: 'This email should be queued for processing.',
|
||||
});
|
||||
|
||||
const session = buildSession(email);
|
||||
const result = await server.processEmailByMode(email, session);
|
||||
expect(result).toBeTruthy();
|
||||
|
||||
const stats = server.deliveryQueue.getStats();
|
||||
expect(stats.modes.process).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('deliver action - queues email for MTA delivery', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'user@local.com',
|
||||
subject: 'Deliver test',
|
||||
text: 'This email should be queued for local delivery.',
|
||||
});
|
||||
|
||||
const session = buildSession(email);
|
||||
const result = await server.processEmailByMode(email, session);
|
||||
expect(result).toBeTruthy();
|
||||
|
||||
const stats = server.deliveryQueue.getStats();
|
||||
expect(stats.modes.mta).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('reject action - throws with correct code', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'bad@spammer.com',
|
||||
to: 'user@process.com',
|
||||
subject: 'Spam attempt',
|
||||
text: 'This should be rejected.',
|
||||
});
|
||||
|
||||
const session = buildSession(email);
|
||||
|
||||
try {
|
||||
await server.processEmailByMode(email, session);
|
||||
throw new Error('Expected processEmailByMode to throw for rejected email');
|
||||
} catch (err: any) {
|
||||
expect(err.responseCode).toEqual(550);
|
||||
expect(err.message).toInclude('Spam rejected');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('forward action - fails to unreachable host', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'user@external.com',
|
||||
subject: 'Forward test',
|
||||
text: 'This forward should fail — no server at port 59999.',
|
||||
});
|
||||
|
||||
const session = buildSession(email);
|
||||
|
||||
try {
|
||||
await server.processEmailByMode(email, session);
|
||||
throw new Error('Expected processEmailByMode to throw for unreachable forward host');
|
||||
} catch (err: any) {
|
||||
// We expect an error from the failed SMTP connection
|
||||
expect(err).toBeTruthy();
|
||||
expect(err.message).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('no matching route - throws error', async () => {
|
||||
if (!bridgeAvailable) {
|
||||
console.log('SKIP: bridge not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'nobody@unmatched.com',
|
||||
subject: 'Unmatched route test',
|
||||
text: 'No route matches this recipient.',
|
||||
});
|
||||
|
||||
const session = buildSession(email);
|
||||
|
||||
try {
|
||||
await server.processEmailByMode(email, session);
|
||||
throw new Error('Expected processEmailByMode to throw for no matching route');
|
||||
} catch (err: any) {
|
||||
expect(err.message).toInclude('No matching route');
|
||||
}
|
||||
});
|
||||
|
||||
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