382 lines
11 KiB
TypeScript
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(); |