dcrouter/test/suite/smtpserver_connection/test.cm-11.keepalive.ts
2025-05-25 19:05:43 +00:00

382 lines
11 KiB
TypeScript

import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
const TEST_PORT = 2525;
let testServer: ITestServer;
const TEST_TIMEOUT = 60000; // Longer timeout for keepalive tests
tap.test('Keepalive - should maintain TCP keepalive', async (tools) => {
const done = tools.defer();
// Start test server
testServer = await startTestServer({ port: TEST_PORT });
await new Promise(resolve => setTimeout(resolve, 1000));
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
// Enable TCP keepalive
const keepAliveDelay = 1000; // 1 second
socket.setKeepAlive(true, keepAliveDelay);
console.log(`TCP keepalive enabled with ${keepAliveDelay}ms delay`);
// Get banner
const banner = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
expect(banner).toInclude('220');
// Send EHLO
socket.write('EHLO testhost\r\n');
const ehloResponse = await new Promise<string>((resolve) => {
let data = '';
const handler = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
socket.removeListener('data', handler);
resolve(data);
}
};
socket.on('data', handler);
});
expect(ehloResponse).toInclude('250');
// Wait for keepalive duration + buffer
console.log('Waiting for keepalive period...');
await new Promise(resolve => setTimeout(resolve, keepAliveDelay + 500));
// Verify connection is still alive by sending NOOP
socket.write('NOOP\r\n');
const noopResponse = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
expect(noopResponse).toInclude('250');
console.log('Connection maintained after keepalive period');
// Clean up
socket.write('QUIT\r\n');
socket.end();
} finally {
await stopTestServer(testServer);
done.resolve();
}
});
tap.test('Keepalive - should maintain idle connection for extended period', async (tools) => {
const done = tools.defer();
// Start test server
testServer = await startTestServer({ port: TEST_PORT });
await new Promise(resolve => setTimeout(resolve, 1000));
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
// Enable keepalive
socket.setKeepAlive(true, 1000);
// Get banner
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
// Send EHLO
socket.write('EHLO testhost\r\n');
await new Promise<string>((resolve) => {
let data = '';
const handler = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
socket.removeListener('data', handler);
resolve(data);
}
};
socket.on('data', handler);
});
// Test multiple keepalive periods
const periods = 3;
const periodDuration = 1000; // 1 second each
for (let i = 0; i < periods; i++) {
console.log(`Keepalive period ${i + 1}/${periods}...`);
await new Promise(resolve => setTimeout(resolve, periodDuration));
// Send NOOP to verify connection
socket.write('NOOP\r\n');
const response = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
expect(response).toInclude('250');
console.log(`Connection alive after ${(i + 1) * periodDuration}ms`);
}
console.log(`Connection maintained for ${periods * periodDuration}ms total`);
// Clean up
socket.write('QUIT\r\n');
socket.end();
} finally {
await stopTestServer(testServer);
done.resolve();
}
});
tap.test('Keepalive - should detect connection loss', async (tools) => {
const done = tools.defer();
// Start test server
testServer = await startTestServer({ port: TEST_PORT });
await new Promise(resolve => setTimeout(resolve, 1000));
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
// Enable keepalive with short interval
socket.setKeepAlive(true, 1000);
// Track connection state
let connectionLost = false;
socket.on('close', () => {
connectionLost = true;
console.log('Connection closed');
});
socket.on('error', (err) => {
connectionLost = true;
console.log('Connection error:', err.message);
});
// Get banner
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
// Send EHLO
socket.write('EHLO testhost\r\n');
await new Promise<string>((resolve) => {
let data = '';
const handler = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
socket.removeListener('data', handler);
resolve(data);
}
};
socket.on('data', handler);
});
console.log('Connection established, now simulating server shutdown...');
// Shutdown server to simulate connection loss
await stopTestServer(testServer);
// Wait for keepalive to detect connection loss
await new Promise(resolve => setTimeout(resolve, 3000));
// Connection should be detected as lost
expect(connectionLost).toEqual(true);
console.log('Keepalive detected connection loss');
} finally {
// Server already shutdown, just resolve
done.resolve();
}
});
tap.test('Keepalive - should handle long-running SMTP session', async (tools) => {
const done = tools.defer();
// Start test server
testServer = await startTestServer({ port: TEST_PORT });
await new Promise(resolve => setTimeout(resolve, 1000));
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
// Enable keepalive
socket.setKeepAlive(true, 2000);
const sessionStart = Date.now();
// Get banner
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
// Send EHLO
socket.write('EHLO testhost\r\n');
await new Promise<string>((resolve) => {
let data = '';
const handler = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
socket.removeListener('data', handler);
resolve(data);
}
};
socket.on('data', handler);
});
// Simulate a long-running session with periodic activity
const activities = [
{ command: 'MAIL FROM:<sender1@example.com>', delay: 500 },
{ command: 'RSET', delay: 500 },
{ command: 'MAIL FROM:<sender2@example.com>', delay: 500 },
{ command: 'RSET', delay: 500 }
];
for (const activity of activities) {
await new Promise(resolve => setTimeout(resolve, activity.delay));
console.log(`Sending: ${activity.command}`);
socket.write(`${activity.command}\r\n`);
const response = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
expect(response).toInclude('250');
}
const sessionDuration = Date.now() - sessionStart;
console.log(`Long-running session maintained for ${sessionDuration}ms`);
// Clean up
socket.write('QUIT\r\n');
const quitResponse = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
expect(quitResponse).toInclude('221');
socket.end();
} finally {
await stopTestServer(testServer);
done.resolve();
}
});
tap.test('Keepalive - should handle NOOP as keepalive mechanism', async (tools) => {
const done = tools.defer();
// Start test server
testServer = await startTestServer({ port: TEST_PORT });
await new Promise(resolve => setTimeout(resolve, 1000));
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
// Get banner
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
// Send EHLO
socket.write('EHLO testhost\r\n');
await new Promise<string>((resolve) => {
let data = '';
const handler = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
socket.removeListener('data', handler);
resolve(data);
}
};
socket.on('data', handler);
});
// Use NOOP as application-level keepalive
const noopInterval = 1000; // 1 second
const noopCount = 3;
console.log(`Sending ${noopCount} NOOP commands as keepalive...`);
for (let i = 0; i < noopCount; i++) {
await new Promise(resolve => setTimeout(resolve, noopInterval));
socket.write('NOOP\r\n');
const response = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
expect(response).toInclude('250');
console.log(`NOOP ${i + 1}/${noopCount} successful`);
}
console.log('Application-level keepalive successful');
// Clean up
socket.write('QUIT\r\n');
socket.end();
} finally {
await stopTestServer(testServer);
done.resolve();
}
});
export default tap.start();