374 lines
10 KiB
TypeScript
374 lines
10 KiB
TypeScript
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
import * as net from 'net';
|
|
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'
|
|
import type { ITestServer } from '../../helpers/server.loader.js';
|
|
// Test configuration
|
|
const TEST_PORT = 2525;
|
|
const TEST_TIMEOUT = 5000;
|
|
|
|
let testServer: ITestServer;
|
|
|
|
// Setup
|
|
tap.test('setup - start SMTP server', async () => {
|
|
testServer = await startTestServer({ port: TEST_PORT });
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
});
|
|
|
|
// Test: Basic connection limit enforcement
|
|
tap.test('Connection Limits - should handle multiple connections gracefully', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const maxConnections = 20; // Test with reasonable number
|
|
const testConnections = maxConnections + 5; // Try 5 more than limit
|
|
const connections: net.Socket[] = [];
|
|
const connectionPromises: Promise<{ index: number; success: boolean; error?: string }>[] = [];
|
|
|
|
// Helper to create a connection with index
|
|
const createConnectionWithIndex = (index: number): Promise<{ index: number; success: boolean; error?: string }> => {
|
|
return new Promise((resolve) => {
|
|
let timeoutHandle: NodeJS.Timeout;
|
|
|
|
try {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
socket.on('connect', () => {
|
|
clearTimeout(timeoutHandle);
|
|
connections[index] = socket;
|
|
|
|
// Wait for server greeting
|
|
socket.on('data', (data) => {
|
|
if (data.toString().includes('220')) {
|
|
resolve({ index, success: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
socket.on('error', (err) => {
|
|
clearTimeout(timeoutHandle);
|
|
resolve({ index, success: false, error: err.message });
|
|
});
|
|
|
|
timeoutHandle = setTimeout(() => {
|
|
socket.destroy();
|
|
resolve({ index, success: false, error: 'Connection timeout' });
|
|
}, TEST_TIMEOUT);
|
|
} catch (err: any) {
|
|
resolve({ index, success: false, error: err.message });
|
|
}
|
|
});
|
|
};
|
|
|
|
// Create connections
|
|
for (let i = 0; i < testConnections; i++) {
|
|
connectionPromises.push(createConnectionWithIndex(i));
|
|
}
|
|
|
|
const results = await Promise.all(connectionPromises);
|
|
|
|
// Count successful connections
|
|
const successfulConnections = results.filter(r => r.success).length;
|
|
const failedConnections = results.filter(r => !r.success).length;
|
|
|
|
// Clean up connections
|
|
for (const socket of connections) {
|
|
if (socket && !socket.destroyed) {
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => socket.destroy(), 100);
|
|
}
|
|
}
|
|
|
|
// Verify results
|
|
expect(successfulConnections).toBeGreaterThan(0);
|
|
|
|
// If some connections were rejected, that's good (limit enforced)
|
|
// If all connections succeeded, that's also acceptable (high/no limit)
|
|
if (failedConnections > 0) {
|
|
console.log(`Server enforced connection limit: ${successfulConnections} accepted, ${failedConnections} rejected`);
|
|
} else {
|
|
console.log(`Server accepted all ${successfulConnections} connections`);
|
|
}
|
|
|
|
done.resolve();
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: Connection limit recovery
|
|
tap.test('Connection Limits - should accept new connections after closing old ones', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const batchSize = 10;
|
|
const firstBatch: net.Socket[] = [];
|
|
const secondBatch: net.Socket[] = [];
|
|
|
|
// Create first batch of connections
|
|
const firstBatchPromises = [];
|
|
for (let i = 0; i < batchSize; i++) {
|
|
firstBatchPromises.push(
|
|
new Promise<boolean>((resolve) => {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
socket.on('connect', () => {
|
|
firstBatch.push(socket);
|
|
socket.on('data', (data) => {
|
|
if (data.toString().includes('220')) {
|
|
resolve(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
socket.on('error', () => resolve(false));
|
|
})
|
|
);
|
|
}
|
|
|
|
const firstResults = await Promise.all(firstBatchPromises);
|
|
const firstSuccessCount = firstResults.filter(r => r).length;
|
|
|
|
// Close first batch
|
|
for (const socket of firstBatch) {
|
|
if (socket && !socket.destroyed) {
|
|
socket.write('QUIT\r\n');
|
|
}
|
|
}
|
|
|
|
// Wait for connections to close
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
// Destroy sockets
|
|
for (const socket of firstBatch) {
|
|
if (socket && !socket.destroyed) {
|
|
socket.destroy();
|
|
}
|
|
}
|
|
|
|
// Create second batch
|
|
const secondBatchPromises = [];
|
|
for (let i = 0; i < batchSize; i++) {
|
|
secondBatchPromises.push(
|
|
new Promise<boolean>((resolve) => {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
socket.on('connect', () => {
|
|
secondBatch.push(socket);
|
|
socket.on('data', (data) => {
|
|
if (data.toString().includes('220')) {
|
|
resolve(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
socket.on('error', () => resolve(false));
|
|
})
|
|
);
|
|
}
|
|
|
|
const secondResults = await Promise.all(secondBatchPromises);
|
|
const secondSuccessCount = secondResults.filter(r => r).length;
|
|
|
|
// Clean up second batch
|
|
for (const socket of secondBatch) {
|
|
if (socket && !socket.destroyed) {
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => socket.destroy(), 100);
|
|
}
|
|
}
|
|
|
|
// Both batches should have successful connections
|
|
expect(firstSuccessCount).toBeGreaterThan(0);
|
|
expect(secondSuccessCount).toBeGreaterThan(0);
|
|
|
|
done.resolve();
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: Rapid connection attempts
|
|
tap.test('Connection Limits - should handle rapid connection attempts', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const rapidConnections = 50;
|
|
const connections: net.Socket[] = [];
|
|
let successCount = 0;
|
|
let errorCount = 0;
|
|
|
|
// Create connections as fast as possible
|
|
for (let i = 0; i < rapidConnections; i++) {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT
|
|
});
|
|
|
|
socket.on('connect', () => {
|
|
connections.push(socket);
|
|
successCount++;
|
|
});
|
|
|
|
socket.on('error', () => {
|
|
errorCount++;
|
|
});
|
|
}
|
|
|
|
// Wait for all connection attempts to settle
|
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
|
|
// Clean up
|
|
for (const socket of connections) {
|
|
if (socket && !socket.destroyed) {
|
|
socket.destroy();
|
|
}
|
|
}
|
|
|
|
// Should handle at least some connections
|
|
expect(successCount).toBeGreaterThan(0);
|
|
console.log(`Rapid connections: ${successCount} succeeded, ${errorCount} failed`);
|
|
|
|
done.resolve();
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: Connection limit with different client IPs (simulated)
|
|
tap.test('Connection Limits - should track connections per IP or globally', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
// Note: In real test, this would use different source IPs
|
|
// For now, we test from same IP but document the behavior
|
|
const connectionsPerIP = 5;
|
|
const connections: net.Socket[] = [];
|
|
const results: boolean[] = [];
|
|
|
|
for (let i = 0; i < connectionsPerIP; i++) {
|
|
const result = await new Promise<boolean>((resolve) => {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
socket.on('connect', () => {
|
|
connections.push(socket);
|
|
socket.on('data', (data) => {
|
|
if (data.toString().includes('220')) {
|
|
resolve(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
socket.on('error', () => resolve(false));
|
|
});
|
|
|
|
results.push(result);
|
|
}
|
|
|
|
const successCount = results.filter(r => r).length;
|
|
|
|
// Clean up
|
|
for (const socket of connections) {
|
|
if (socket && !socket.destroyed) {
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => socket.destroy(), 100);
|
|
}
|
|
}
|
|
|
|
// Should accept connections from same IP
|
|
expect(successCount).toBeGreaterThan(0);
|
|
console.log(`Per-IP connections: ${successCount} of ${connectionsPerIP} succeeded`);
|
|
|
|
done.resolve();
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: Connection limit error messages
|
|
tap.test('Connection Limits - should provide meaningful error when limit reached', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const manyConnections = 100;
|
|
const connections: net.Socket[] = [];
|
|
const errors: string[] = [];
|
|
let rejected = false;
|
|
|
|
// Create many connections to try to hit limit
|
|
const promises = [];
|
|
for (let i = 0; i < manyConnections; i++) {
|
|
promises.push(
|
|
new Promise<void>((resolve) => {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: 1000
|
|
});
|
|
|
|
socket.on('connect', () => {
|
|
connections.push(socket);
|
|
|
|
socket.on('data', (data) => {
|
|
const response = data.toString();
|
|
// Check if server sends connection limit message
|
|
if (response.includes('421') || response.includes('too many connections')) {
|
|
rejected = true;
|
|
errors.push(response);
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
socket.on('error', (err) => {
|
|
if (err.message.includes('ECONNREFUSED') || err.message.includes('ECONNRESET')) {
|
|
rejected = true;
|
|
errors.push(err.message);
|
|
}
|
|
resolve();
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
resolve();
|
|
});
|
|
})
|
|
);
|
|
}
|
|
|
|
await Promise.all(promises);
|
|
|
|
// Clean up
|
|
for (const socket of connections) {
|
|
if (socket && !socket.destroyed) {
|
|
socket.destroy();
|
|
}
|
|
}
|
|
|
|
// Log results
|
|
console.log(`Connection limit test: ${connections.length} connected, ${errors.length} rejected`);
|
|
if (rejected) {
|
|
console.log(`Sample rejection: ${errors[0]}`);
|
|
}
|
|
|
|
// Should have handled connections (either accepted or properly rejected)
|
|
expect(connections.length + errors.length).toBeGreaterThan(0);
|
|
|
|
done.resolve();
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Teardown
|
|
tap.test('teardown - stop SMTP server', async () => {
|
|
await stopTestServer(testServer);
|
|
});
|
|
|
|
// Start the test
|
|
export default tap.start(); |