Remove obsolete test files for SenderReputationMonitor, DcRouter, and StorageManager
- Deleted tests for SenderReputationMonitor, IPWarmupManager, and related functionality. - Removed tests for DcRouter including health status, server statistics, and configuration requests. - Eliminated tests for protected endpoints and admin login functionality. - Cleared out tests for socket handler integration and unit tests. - Removed tests for StorageManager covering memory, filesystem, and custom function backends.
This commit is contained in:
@@ -1,65 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import * as paths from '../ts/paths.ts';
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.ts';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.ts';
|
||||
|
||||
/**
|
||||
* Basic test to check if our integrated classes work correctly
|
||||
*/
|
||||
tap.test('verify that SenderReputationMonitor and IPWarmupManager are functioning', async () => {
|
||||
// Create instances of both classes
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com']
|
||||
});
|
||||
|
||||
// Test SenderReputationMonitor
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
|
||||
const reputationData = reputationMonitor.getReputationData('example.com');
|
||||
expect(reputationData).toBeTruthy();
|
||||
|
||||
const summary = reputationMonitor.getReputationSummary();
|
||||
expect(summary.length).toBeGreaterThan(0);
|
||||
|
||||
// Add and remove domains
|
||||
reputationMonitor.addDomain('test.com');
|
||||
reputationMonitor.removeDomain('test.com');
|
||||
|
||||
// Test IPWarmupManager
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
const bestIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
if (bestIP) {
|
||||
ipWarmupManager.recordSend(bestIP);
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday(bestIP);
|
||||
expect(typeof canSendMore).toEqual('boolean');
|
||||
}
|
||||
|
||||
const stageCount = ipWarmupManager.getStageCount();
|
||||
expect(stageCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op - just to make sure everything is cleaned up properly
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,201 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import {
|
||||
DcRouter,
|
||||
type IDcRouterOptions,
|
||||
type IEmailConfig,
|
||||
type EmailProcessingMode,
|
||||
type IDomainRule
|
||||
} from '../ts/classes.dcrouter.ts';
|
||||
|
||||
|
||||
tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
// Define custom port mapping
|
||||
const customPortMapping = {
|
||||
25: 11025, // Custom SMTP port mapping
|
||||
587: 11587, // Custom submission port mapping
|
||||
465: 11465, // Custom SMTPS port mapping
|
||||
2525: 12525 // Additional custom port
|
||||
};
|
||||
|
||||
// Create a custom email configuration
|
||||
const emailConfig: IEmailConfig = {
|
||||
ports: [25, 587, 465, 2525], // Added a non-standard port
|
||||
hostname: 'mail.example.com',
|
||||
maxMessageSize: 50 * 1024 * 1024, // 50MB
|
||||
|
||||
defaultMode: 'forward' as EmailProcessingMode,
|
||||
defaultServer: 'fallback-mail.example.com',
|
||||
defaultPort: 25,
|
||||
defaultTls: true,
|
||||
|
||||
domainRules: [
|
||||
{
|
||||
pattern: '*@example.com',
|
||||
mode: 'forward' as EmailProcessingMode,
|
||||
target: {
|
||||
server: 'mail1.example.com',
|
||||
port: 25,
|
||||
useTls: true
|
||||
}
|
||||
},
|
||||
{
|
||||
pattern: '*@example.org',
|
||||
mode: 'mta' as EmailProcessingMode,
|
||||
mtaOptions: {
|
||||
domain: 'example.org',
|
||||
allowLocalDelivery: true
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Create custom email storage path
|
||||
const customEmailsPath = path.join(process.cwd(), 'email');
|
||||
|
||||
// Ensure directory exists and is empty
|
||||
if (fs.existsSync(customEmailsPath)) {
|
||||
try {
|
||||
fs.rmdirSync(customEmailsPath, { recursive: true });
|
||||
} catch (e) {
|
||||
console.warn('Could not remove test directory:', e);
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(customEmailsPath, { recursive: true });
|
||||
|
||||
// Create DcRouter options with custom email port configuration
|
||||
const options: IDcRouterOptions = {
|
||||
emailConfig,
|
||||
emailPortConfig: {
|
||||
portMapping: customPortMapping,
|
||||
portSettings: {
|
||||
2525: {
|
||||
terminateTls: false,
|
||||
routeName: 'custom-smtp-route'
|
||||
}
|
||||
},
|
||||
receivedEmailsPath: customEmailsPath
|
||||
},
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
}
|
||||
};
|
||||
|
||||
// Create DcRouter instance
|
||||
const router = new DcRouter(options);
|
||||
|
||||
// Verify the options are correctly set
|
||||
expect(router.options.emailPortConfig).toBeTruthy();
|
||||
expect(router.options.emailPortConfig.portMapping).toEqual(customPortMapping);
|
||||
expect(router.options.emailPortConfig.receivedEmailsPath).toEqual(customEmailsPath);
|
||||
|
||||
// Test the generateEmailRoutes method
|
||||
if (typeof router['generateEmailRoutes'] === 'function') {
|
||||
const routes = router['generateEmailRoutes'](emailConfig);
|
||||
|
||||
// Verify that all ports are configured
|
||||
expect(routes.length).toBeGreaterThan(0); // At least some routes are configured
|
||||
|
||||
// Check the custom port configuration
|
||||
const customPortRoute = routes.find(r => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 2525 || (Array.isArray(ports) && (ports as number[]).includes(2525));
|
||||
});
|
||||
expect(customPortRoute).toBeTruthy();
|
||||
expect(customPortRoute?.name).toEqual('custom-smtp-route');
|
||||
expect(customPortRoute?.action.target.port).toEqual(12525);
|
||||
|
||||
// Check standard port mappings
|
||||
const smtpRoute = routes.find(r => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 25 || (Array.isArray(ports) && (ports as number[]).includes(25));
|
||||
});
|
||||
expect(smtpRoute?.action.target.port).toEqual(11025);
|
||||
|
||||
const submissionRoute = routes.find(r => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 587 || (Array.isArray(ports) && (ports as number[]).includes(587));
|
||||
});
|
||||
expect(submissionRoute?.action.target.port).toEqual(11587);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
try {
|
||||
fs.rmdirSync(customEmailsPath, { recursive: true });
|
||||
} catch (e) {
|
||||
console.warn('Could not remove test directory in cleanup:', e);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Custom email storage path', async () => {
|
||||
// Create custom email storage path
|
||||
const customEmailsPath = path.join(process.cwd(), 'email');
|
||||
|
||||
// Ensure directory exists and is empty
|
||||
if (fs.existsSync(customEmailsPath)) {
|
||||
try {
|
||||
fs.rmdirSync(customEmailsPath, { recursive: true });
|
||||
} catch (e) {
|
||||
console.warn('Could not remove test directory:', e);
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(customEmailsPath, { recursive: true });
|
||||
|
||||
// Create a basic email configuration
|
||||
const emailConfig: IEmailConfig = {
|
||||
ports: [25],
|
||||
hostname: 'mail.example.com',
|
||||
defaultMode: 'mta' as EmailProcessingMode,
|
||||
domainRules: []
|
||||
};
|
||||
|
||||
// Create DcRouter options with custom email storage path
|
||||
const options: IDcRouterOptions = {
|
||||
emailConfig,
|
||||
emailPortConfig: {
|
||||
receivedEmailsPath: customEmailsPath
|
||||
},
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
}
|
||||
};
|
||||
|
||||
// Create DcRouter instance
|
||||
const router = new DcRouter(options);
|
||||
|
||||
// Start the router to initialize email services
|
||||
await router.start();
|
||||
|
||||
// Verify that the custom email storage path was configured
|
||||
expect(router.options.emailPortConfig?.receivedEmailsPath).toEqual(customEmailsPath);
|
||||
|
||||
// Verify the directory exists
|
||||
expect(fs.existsSync(customEmailsPath)).toEqual(true);
|
||||
|
||||
// Verify unified email server was initialized
|
||||
expect(router.unifiedEmailServer).toBeTruthy();
|
||||
|
||||
// Stop the router
|
||||
await router.stop();
|
||||
|
||||
// Clean up
|
||||
try {
|
||||
fs.rmdirSync(customEmailsPath, { recursive: true });
|
||||
} catch (e) {
|
||||
console.warn('Could not remove test directory in cleanup:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op - just to make sure everything is cleaned up properly
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
// Export a function to run all tests
|
||||
export default tap.start();
|
||||
@@ -1,55 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import * as paths from '../ts/paths.ts';
|
||||
|
||||
// Import the components we want to test
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.ts';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.ts';
|
||||
|
||||
// Ensure test directories exist
|
||||
paths.ensureDirectories();
|
||||
|
||||
// Test SenderReputationMonitor functionality
|
||||
tap.test('SenderReputationMonitor should track sending events', async () => {
|
||||
// Initialize monitor with test domain
|
||||
const monitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['test-domain.com']
|
||||
});
|
||||
|
||||
// Record some events
|
||||
monitor.recordSendEvent('test-domain.com', { type: 'sent', count: 100 });
|
||||
monitor.recordSendEvent('test-domain.com', { type: 'delivered', count: 95 });
|
||||
|
||||
// Get domain metrics
|
||||
const metrics = monitor.getReputationData('test-domain.com');
|
||||
|
||||
// Verify metrics were recorded
|
||||
if (metrics) {
|
||||
expect(metrics.volume.sent).toEqual(100);
|
||||
expect(metrics.volume.delivered).toEqual(95);
|
||||
}
|
||||
});
|
||||
|
||||
// Test IPWarmupManager functionality
|
||||
tap.test('IPWarmupManager should handle IP allocation policies', async () => {
|
||||
// Initialize warmup manager
|
||||
const manager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['test-domain.com']
|
||||
});
|
||||
|
||||
// Set allocation policy
|
||||
manager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
// Verify allocation methods work
|
||||
const canSend = manager.canSendMoreToday('192.168.1.1');
|
||||
expect(typeof canSend).toEqual('boolean');
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,141 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import * as paths from '../ts/paths.ts';
|
||||
import { DnsManager } from '../ts/mail/routing/classes.dns.manager.ts';
|
||||
import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.ts';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.ts';
|
||||
import type { IEmailDomainConfig } from '../ts/mail/routing/interfaces.ts';
|
||||
|
||||
// Mock DcRouter with DNS server
|
||||
class MockDcRouter {
|
||||
public storageManager: StorageManager;
|
||||
public dnsServer: any;
|
||||
public options: any;
|
||||
private dnsHandlers: Map<string, any> = new Map();
|
||||
|
||||
constructor(testDir: string, dnsDomain?: string) {
|
||||
this.storageManager = new StorageManager({ fsPath: testDir });
|
||||
this.options = { dnsDomain };
|
||||
|
||||
// Mock DNS server
|
||||
this.dnsServer = {
|
||||
registerHandler: (name: string, types: string[], handler: () => any) => {
|
||||
const key = `${name}:${types.join(',')}`;
|
||||
this.dnsHandlers.set(key, handler);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getDnsHandler(name: string, type: string): any {
|
||||
const key = `${name}:${type}`;
|
||||
return this.dnsHandlers.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('DnsManager - Create Internal DNS Records', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-dns-manager-creation');
|
||||
const mockRouter = new MockDcRouter(testDir, 'ns.test.com') as any;
|
||||
const dnsManager = new DnsManager(mockRouter);
|
||||
|
||||
const domainConfigs: IEmailDomainConfig[] = [
|
||||
{
|
||||
domain: 'test.example.com',
|
||||
dnsMode: 'internal-dns',
|
||||
dns: {
|
||||
internal: {
|
||||
mxPriority: 15,
|
||||
ttl: 7200
|
||||
}
|
||||
},
|
||||
dkim: {
|
||||
selector: 'test2024',
|
||||
keySize: 2048
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Create DNS records
|
||||
await dnsManager.ensureDnsRecords(domainConfigs);
|
||||
|
||||
// Verify MX record was registered
|
||||
const mxHandler = mockRouter.getDnsHandler('test.example.com', 'MX');
|
||||
expect(mxHandler).toBeTruthy();
|
||||
const mxRecord = mxHandler();
|
||||
expect(mxRecord.type).toEqual('MX');
|
||||
expect(mxRecord.data.priority).toEqual(15);
|
||||
expect(mxRecord.data.exchange).toEqual('test.example.com');
|
||||
expect(mxRecord.ttl).toEqual(7200);
|
||||
|
||||
// Verify SPF record was registered
|
||||
const txtHandler = mockRouter.getDnsHandler('test.example.com', 'TXT');
|
||||
expect(txtHandler).toBeTruthy();
|
||||
const spfRecord = txtHandler();
|
||||
expect(spfRecord.type).toEqual('TXT');
|
||||
expect(spfRecord.data).toEqual('v=spf1 a mx ~all');
|
||||
|
||||
// Verify DMARC record was registered
|
||||
const dmarcHandler = mockRouter.getDnsHandler('_dmarc.test.example.com', 'TXT');
|
||||
expect(dmarcHandler).toBeTruthy();
|
||||
const dmarcRecord = dmarcHandler();
|
||||
expect(dmarcRecord.type).toEqual('TXT');
|
||||
expect(dmarcRecord.data).toContain('v=DMARC1');
|
||||
expect(dmarcRecord.data).toContain('p=none');
|
||||
|
||||
// Verify records were stored in StorageManager
|
||||
const mxStored = await mockRouter.storageManager.getJSON('/email/dns/test.example.com/mx');
|
||||
expect(mxStored).toBeTruthy();
|
||||
expect(mxStored.priority).toEqual(15);
|
||||
|
||||
const spfStored = await mockRouter.storageManager.getJSON('/email/dns/test.example.com/spf');
|
||||
expect(spfStored).toBeTruthy();
|
||||
expect(spfStored.data).toEqual('v=spf1 a mx ~all');
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('DnsManager - Create DKIM Records', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-dns-manager-dkim');
|
||||
const keysDir = plugins.path.join(testDir, 'keys');
|
||||
await plugins.fs.promises.mkdir(keysDir, { recursive: true });
|
||||
|
||||
const mockRouter = new MockDcRouter(testDir, 'ns.test.com') as any;
|
||||
const dnsManager = new DnsManager(mockRouter);
|
||||
const dkimCreator = new DKIMCreator(keysDir, mockRouter.storageManager);
|
||||
|
||||
const domainConfigs: IEmailDomainConfig[] = [
|
||||
{
|
||||
domain: 'dkim.example.com',
|
||||
dnsMode: 'internal-dns',
|
||||
dkim: {
|
||||
selector: 'mail2024',
|
||||
keySize: 2048
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Generate DKIM keys first
|
||||
await dkimCreator.handleDKIMKeysForDomain('dkim.example.com');
|
||||
|
||||
// Create DNS records including DKIM
|
||||
await dnsManager.ensureDnsRecords(domainConfigs, dkimCreator);
|
||||
|
||||
// Verify DKIM record was registered
|
||||
const dkimHandler = mockRouter.getDnsHandler('mail2024._domainkey.dkim.example.com', 'TXT');
|
||||
expect(dkimHandler).toBeTruthy();
|
||||
const dkimRecord = dkimHandler();
|
||||
expect(dkimRecord.type).toEqual('TXT');
|
||||
expect(dkimRecord.data).toContain('v=DKIM1');
|
||||
expect(dkimRecord.data).toContain('k=rsa');
|
||||
expect(dkimRecord.data).toContain('p=');
|
||||
|
||||
// Verify DKIM record was stored
|
||||
const dkimStored = await mockRouter.storageManager.getJSON('/email/dns/dkim.example.com/dkim');
|
||||
expect(dkimStored).toBeTruthy();
|
||||
expect(dkimStored.name).toEqual('mail2024._domainkey.dkim.example.com');
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,257 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import * as paths from '../ts/paths.ts';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.ts';
|
||||
import { DnsManager } from '../ts/mail/routing/classes.dns.manager.ts';
|
||||
import { DomainRegistry } from '../ts/mail/routing/classes.domain.registry.ts';
|
||||
import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.ts';
|
||||
import type { IEmailDomainConfig } from '../ts/mail/routing/interfaces.ts';
|
||||
|
||||
// Mock DcRouter for testing
|
||||
class MockDcRouter {
|
||||
public storageManager: StorageManager;
|
||||
public options: any;
|
||||
|
||||
constructor(testDir: string, dnsDomain?: string) {
|
||||
this.storageManager = new StorageManager({ fsPath: testDir });
|
||||
this.options = {
|
||||
dnsDomain
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('DNS Mode Switching - Forward to Internal', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-dns-mode-switch-1');
|
||||
const keysDir = plugins.path.join(testDir, 'keys');
|
||||
await plugins.fs.promises.mkdir(keysDir, { recursive: true });
|
||||
|
||||
const mockRouter = new MockDcRouter(testDir, 'ns.test.com') as any;
|
||||
const dkimCreator = new DKIMCreator(keysDir, mockRouter.storageManager);
|
||||
|
||||
// Phase 1: Start with forward mode
|
||||
let config: IEmailDomainConfig = {
|
||||
domain: 'switchtest1.com',
|
||||
dnsMode: 'forward',
|
||||
dns: {
|
||||
forward: {
|
||||
skipDnsValidation: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let registry = new DomainRegistry([config]);
|
||||
let domainConfig = registry.getDomainConfig('switchtest1.com');
|
||||
|
||||
expect(domainConfig?.dnsMode).toEqual('forward');
|
||||
|
||||
// DKIM keys should still be generated for consistency
|
||||
await dkimCreator.handleDKIMKeysForDomain('switchtest1.com');
|
||||
const keys = await dkimCreator.readDKIMKeys('switchtest1.com');
|
||||
expect(keys.privateKey).toBeTruthy();
|
||||
|
||||
// Phase 2: Switch to internal-dns mode
|
||||
config = {
|
||||
domain: 'switchtest1.com',
|
||||
dnsMode: 'internal-dns',
|
||||
dns: {
|
||||
internal: {
|
||||
mxPriority: 20,
|
||||
ttl: 7200
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
registry = new DomainRegistry([config]);
|
||||
domainConfig = registry.getDomainConfig('switchtest1.com');
|
||||
|
||||
expect(domainConfig?.dnsMode).toEqual('internal-dns');
|
||||
expect(domainConfig?.dns?.internal?.mxPriority).toEqual(20);
|
||||
|
||||
// DKIM keys should persist across mode switches
|
||||
const keysAfterSwitch = await dkimCreator.readDKIMKeys('switchtest1.com');
|
||||
expect(keysAfterSwitch.privateKey).toEqual(keys.privateKey);
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('DNS Mode Switching - External to Forward', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-dns-mode-switch-2');
|
||||
const keysDir = plugins.path.join(testDir, 'keys');
|
||||
await plugins.fs.promises.mkdir(keysDir, { recursive: true });
|
||||
|
||||
const mockRouter = new MockDcRouter(testDir) as any;
|
||||
const dkimCreator = new DKIMCreator(keysDir, mockRouter.storageManager);
|
||||
|
||||
// Phase 1: Start with external-dns mode
|
||||
let config: IEmailDomainConfig = {
|
||||
domain: 'switchtest2.com',
|
||||
dnsMode: 'external-dns',
|
||||
dns: {
|
||||
external: {
|
||||
requiredRecords: ['MX', 'SPF', 'DKIM']
|
||||
}
|
||||
},
|
||||
dkim: {
|
||||
selector: 'custom2024',
|
||||
keySize: 4096
|
||||
}
|
||||
};
|
||||
|
||||
let registry = new DomainRegistry([config]);
|
||||
let domainConfig = registry.getDomainConfig('switchtest2.com');
|
||||
|
||||
expect(domainConfig?.dnsMode).toEqual('external-dns');
|
||||
expect(domainConfig?.dkim?.selector).toEqual('custom2024');
|
||||
expect(domainConfig?.dkim?.keySize).toEqual(4096);
|
||||
|
||||
// Generate DKIM keys (always uses default selector initially)
|
||||
await dkimCreator.handleDKIMKeysForDomain('switchtest2.com');
|
||||
// For custom selector, we would need to implement key rotation
|
||||
const dnsRecord = await dkimCreator.getDNSRecordForDomain('switchtest2.com');
|
||||
expect(dnsRecord.name).toContain('mta._domainkey');
|
||||
|
||||
// Phase 2: Switch to forward mode
|
||||
config = {
|
||||
domain: 'switchtest2.com',
|
||||
dnsMode: 'forward',
|
||||
dns: {
|
||||
forward: {
|
||||
targetDomain: 'mail.forward.com'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
registry = new DomainRegistry([config]);
|
||||
domainConfig = registry.getDomainConfig('switchtest2.com');
|
||||
|
||||
expect(domainConfig?.dnsMode).toEqual('forward');
|
||||
expect(domainConfig?.dns?.forward?.targetDomain).toEqual('mail.forward.com');
|
||||
|
||||
// DKIM configuration should revert to defaults
|
||||
expect(domainConfig?.dkim?.selector).toEqual('default');
|
||||
expect(domainConfig?.dkim?.keySize).toEqual(2048);
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('DNS Mode Switching - Multiple Domains Different Modes', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-dns-mode-switch-3');
|
||||
const mockRouter = new MockDcRouter(testDir, 'ns.multi.com') as any;
|
||||
|
||||
// Configure multiple domains with different modes
|
||||
const domains: IEmailDomainConfig[] = [
|
||||
{
|
||||
domain: 'forward.multi.com',
|
||||
dnsMode: 'forward'
|
||||
},
|
||||
{
|
||||
domain: 'internal.multi.com',
|
||||
dnsMode: 'internal-dns',
|
||||
dns: {
|
||||
internal: {
|
||||
mxPriority: 5
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
domain: 'external.multi.com',
|
||||
dnsMode: 'external-dns',
|
||||
rateLimits: {
|
||||
inbound: {
|
||||
messagesPerMinute: 50
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const registry = new DomainRegistry(domains);
|
||||
|
||||
// Verify each domain has correct mode
|
||||
expect(registry.getDomainConfig('forward.multi.com')?.dnsMode).toEqual('forward');
|
||||
expect(registry.getDomainConfig('internal.multi.com')?.dnsMode).toEqual('internal-dns');
|
||||
expect(registry.getDomainConfig('external.multi.com')?.dnsMode).toEqual('external-dns');
|
||||
|
||||
// Verify mode-specific configurations
|
||||
expect(registry.getDomainConfig('internal.multi.com')?.dns?.internal?.mxPriority).toEqual(5);
|
||||
expect(registry.getDomainConfig('external.multi.com')?.rateLimits?.inbound?.messagesPerMinute).toEqual(50);
|
||||
|
||||
// Get domains by mode
|
||||
const forwardDomains = registry.getDomainsByMode('forward');
|
||||
const internalDomains = registry.getDomainsByMode('internal-dns');
|
||||
const externalDomains = registry.getDomainsByMode('external-dns');
|
||||
|
||||
expect(forwardDomains.length).toEqual(1);
|
||||
expect(forwardDomains[0].domain).toEqual('forward.multi.com');
|
||||
|
||||
expect(internalDomains.length).toEqual(1);
|
||||
expect(internalDomains[0].domain).toEqual('internal.multi.com');
|
||||
|
||||
expect(externalDomains.length).toEqual(1);
|
||||
expect(externalDomains[0].domain).toEqual('external.multi.com');
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('DNS Mode Switching - Configuration Persistence', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-dns-mode-switch-4');
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
|
||||
// Save domain configuration
|
||||
const config: IEmailDomainConfig = {
|
||||
domain: 'persist.test.com',
|
||||
dnsMode: 'internal-dns',
|
||||
dns: {
|
||||
internal: {
|
||||
mxPriority: 15,
|
||||
ttl: 1800
|
||||
}
|
||||
},
|
||||
dkim: {
|
||||
selector: 'persist2024',
|
||||
rotateKeys: true,
|
||||
rotationInterval: 30
|
||||
},
|
||||
rateLimits: {
|
||||
outbound: {
|
||||
messagesPerHour: 1000
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save to storage
|
||||
await storage.setJSON('/email/domains/persist.test.com', config);
|
||||
|
||||
// Simulate restart - load from storage
|
||||
const loadedConfig = await storage.getJSON<IEmailDomainConfig>('/email/domains/persist.test.com');
|
||||
|
||||
expect(loadedConfig).toBeTruthy();
|
||||
expect(loadedConfig?.dnsMode).toEqual('internal-dns');
|
||||
expect(loadedConfig?.dns?.internal?.mxPriority).toEqual(15);
|
||||
expect(loadedConfig?.dkim?.selector).toEqual('persist2024');
|
||||
expect(loadedConfig?.dkim?.rotateKeys).toEqual(true);
|
||||
expect(loadedConfig?.rateLimits?.outbound?.messagesPerHour).toEqual(1000);
|
||||
|
||||
// Update DNS mode
|
||||
if (loadedConfig) {
|
||||
loadedConfig.dnsMode = 'forward';
|
||||
loadedConfig.dns = {
|
||||
forward: {
|
||||
skipDnsValidation: false
|
||||
}
|
||||
};
|
||||
await storage.setJSON('/email/domains/persist.test.com', loadedConfig);
|
||||
}
|
||||
|
||||
// Load updated config
|
||||
const updatedConfig = await storage.getJSON<IEmailDomainConfig>('/email/domains/persist.test.com');
|
||||
expect(updatedConfig?.dnsMode).toEqual('forward');
|
||||
expect(updatedConfig?.dns?.forward?.skipDnsValidation).toEqual(false);
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,169 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.ts';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
|
||||
let dcRouter: DcRouter;
|
||||
|
||||
tap.test('should NOT instantiate DNS server when dnsDomain is not set', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Check that DNS server is not created
|
||||
expect((dcRouter as any).dnsServer).toBeUndefined();
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('should instantiate DNS server when dnsDomain is set', async () => {
|
||||
// Use a non-standard port to avoid conflicts
|
||||
const testPort = 8443;
|
||||
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.test.local',
|
||||
smartProxyConfig: {
|
||||
routes: [],
|
||||
portMappings: {
|
||||
443: testPort // Map port 443 to test port
|
||||
}
|
||||
} as any
|
||||
});
|
||||
|
||||
try {
|
||||
await dcRouter.start();
|
||||
} catch (error) {
|
||||
// If start fails due to port conflict, that's OK for this test
|
||||
// We're mainly testing the route generation logic
|
||||
}
|
||||
|
||||
// Check that DNS server is created
|
||||
expect((dcRouter as any).dnsServer).toBeDefined();
|
||||
|
||||
// Check routes were generated (even if SmartProxy failed to start)
|
||||
const generatedRoutes = (dcRouter as any).generateDnsRoutes();
|
||||
expect(generatedRoutes.length).toEqual(2); // /dns-query and /resolve
|
||||
|
||||
// Check that routes have socket-handler action
|
||||
generatedRoutes.forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('socket-handler');
|
||||
expect(route.action.socketHandler).toBeDefined();
|
||||
});
|
||||
|
||||
try {
|
||||
await dcRouter.stop();
|
||||
} catch (error) {
|
||||
// Ignore stop errors
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should create DNS routes with correct configuration', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.example.com',
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
// Access the private method to generate routes
|
||||
const dnsRoutes = (dcRouter as any).generateDnsRoutes();
|
||||
|
||||
expect(dnsRoutes.length).toEqual(2);
|
||||
|
||||
// Check first route (dns-query)
|
||||
const dnsQueryRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-dns-query');
|
||||
expect(dnsQueryRoute).toBeDefined();
|
||||
expect(dnsQueryRoute.match.ports).toContain(443);
|
||||
expect(dnsQueryRoute.match.domains).toContain('dns.example.com');
|
||||
expect(dnsQueryRoute.match.path).toEqual('/dns-query');
|
||||
|
||||
// Check second route (resolve)
|
||||
const resolveRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-resolve');
|
||||
expect(resolveRoute).toBeDefined();
|
||||
expect(resolveRoute.match.ports).toContain(443);
|
||||
expect(resolveRoute.match.domains).toContain('dns.example.com');
|
||||
expect(resolveRoute.match.path).toEqual('/resolve');
|
||||
});
|
||||
|
||||
tap.test('DNS socket handler should handle sockets correctly', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.test.local',
|
||||
smartProxyConfig: {
|
||||
routes: [],
|
||||
portMappings: { 443: 8444 } // Use different test port
|
||||
} as any
|
||||
});
|
||||
|
||||
try {
|
||||
await dcRouter.start();
|
||||
} catch (error) {
|
||||
// Ignore start errors for this test
|
||||
}
|
||||
|
||||
// Create a mock socket
|
||||
const mockSocket = new plugins.net.Socket();
|
||||
let socketEnded = false;
|
||||
let socketDestroyed = false;
|
||||
|
||||
mockSocket.end = () => {
|
||||
socketEnded = true;
|
||||
};
|
||||
|
||||
mockSocket.destroy = () => {
|
||||
socketDestroyed = true;
|
||||
};
|
||||
|
||||
// Get the socket handler
|
||||
const socketHandler = (dcRouter as any).createDnsSocketHandler();
|
||||
expect(socketHandler).toBeDefined();
|
||||
expect(typeof socketHandler).toEqual('function');
|
||||
|
||||
// Test with DNS server initialized
|
||||
try {
|
||||
await socketHandler(mockSocket);
|
||||
} catch (error) {
|
||||
// Expected - mock socket won't work properly
|
||||
}
|
||||
|
||||
// Socket should be handled by DNS server (even if it errors)
|
||||
expect(socketHandler).toBeDefined();
|
||||
|
||||
try {
|
||||
await dcRouter.stop();
|
||||
} catch (error) {
|
||||
// Ignore stop errors
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DNS server should have manual HTTPS mode enabled', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.test.local'
|
||||
});
|
||||
|
||||
// Don't actually start it to avoid port conflicts
|
||||
// Instead, directly call the setup method
|
||||
try {
|
||||
await (dcRouter as any).setupDnsWithSocketHandler();
|
||||
} catch (error) {
|
||||
// May fail but that's OK
|
||||
}
|
||||
|
||||
// Check that DNS server was created with correct options
|
||||
const dnsServer = (dcRouter as any).dnsServer;
|
||||
expect(dnsServer).toBeDefined();
|
||||
|
||||
// The important thing is that the DNS routes are created correctly
|
||||
// and that the socket handler is set up
|
||||
const socketHandler = (dcRouter as any).createDnsSocketHandler();
|
||||
expect(socketHandler).toBeDefined();
|
||||
expect(typeof socketHandler).toEqual('function');
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,283 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import * as paths from '../ts/paths.ts';
|
||||
import { DnsManager } from '../ts/mail/routing/classes.dns.manager.ts';
|
||||
import { DomainRegistry } from '../ts/mail/routing/classes.domain.registry.ts';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.ts';
|
||||
import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.ts';
|
||||
import type { IEmailDomainConfig } from '../ts/mail/routing/interfaces.ts';
|
||||
|
||||
// Mock DcRouter for testing
|
||||
class MockDcRouter {
|
||||
public storageManager: StorageManager;
|
||||
public options: any;
|
||||
|
||||
constructor(testDir: string, dnsDomain?: string) {
|
||||
this.storageManager = new StorageManager({ fsPath: testDir });
|
||||
this.options = {
|
||||
dnsDomain
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Mock DNS resolver for testing
|
||||
class MockDnsManager extends DnsManager {
|
||||
private mockNsRecords: Map<string, string[]> = new Map();
|
||||
private mockTxtRecords: Map<string, string[][]> = new Map();
|
||||
private mockMxRecords: Map<string, any[]> = new Map();
|
||||
|
||||
setNsRecords(domain: string, records: string[]) {
|
||||
this.mockNsRecords.set(domain, records);
|
||||
}
|
||||
|
||||
setTxtRecords(domain: string, records: string[][]) {
|
||||
this.mockTxtRecords.set(domain, records);
|
||||
}
|
||||
|
||||
setMxRecords(domain: string, records: any[]) {
|
||||
this.mockMxRecords.set(domain, records);
|
||||
}
|
||||
|
||||
protected async resolveNs(domain: string): Promise<string[]> {
|
||||
return this.mockNsRecords.get(domain) || [];
|
||||
}
|
||||
|
||||
protected async resolveTxt(domain: string): Promise<string[][]> {
|
||||
return this.mockTxtRecords.get(domain) || [];
|
||||
}
|
||||
|
||||
protected async resolveMx(domain: string): Promise<any[]> {
|
||||
return this.mockMxRecords.get(domain) || [];
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('DNS Validator - Forward Mode', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-dns-forward');
|
||||
const mockRouter = new MockDcRouter(testDir) as any;
|
||||
const validator = new DnsManager(mockRouter);
|
||||
|
||||
const config: IEmailDomainConfig = {
|
||||
domain: 'forward.example.com',
|
||||
dnsMode: 'forward',
|
||||
dns: {
|
||||
forward: {
|
||||
skipDnsValidation: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateDomain(config);
|
||||
|
||||
expect(result.valid).toEqual(true);
|
||||
expect(result.errors.length).toEqual(0);
|
||||
expect(result.warnings.length).toBeGreaterThan(0); // Should have warning about forward mode
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('DNS Validator - Internal DNS Mode', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-dns-internal');
|
||||
const mockRouter = new MockDcRouter(testDir, 'ns.myservice.com') as any;
|
||||
const validator = new MockDnsManager(mockRouter);
|
||||
|
||||
// Setup NS delegation
|
||||
validator.setNsRecords('mail.example.com', ['ns.myservice.com']);
|
||||
|
||||
const config: IEmailDomainConfig = {
|
||||
domain: 'mail.example.com',
|
||||
dnsMode: 'internal-dns',
|
||||
dns: {
|
||||
internal: {
|
||||
mxPriority: 10,
|
||||
ttl: 3600
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateDomain(config);
|
||||
|
||||
expect(result.valid).toEqual(true);
|
||||
expect(result.errors.length).toEqual(0);
|
||||
|
||||
// Test without NS delegation
|
||||
validator.setNsRecords('mail2.example.com', ['other.nameserver.com']);
|
||||
|
||||
const config2: IEmailDomainConfig = {
|
||||
domain: 'mail2.example.com',
|
||||
dnsMode: 'internal-dns'
|
||||
};
|
||||
|
||||
const result2 = await validator.validateDomain(config2);
|
||||
|
||||
// Should have warnings but still be valid (warnings don't make it invalid)
|
||||
expect(result2.valid).toEqual(true);
|
||||
expect(result2.warnings.length).toBeGreaterThan(0);
|
||||
expect(result2.requiredChanges.length).toBeGreaterThan(0);
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('DNS Validator - External DNS Mode', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-dns-external');
|
||||
const mockRouter = new MockDcRouter(testDir) as any;
|
||||
const validator = new MockDnsManager(mockRouter);
|
||||
|
||||
// Setup mock DNS records
|
||||
validator.setMxRecords('example.com', [
|
||||
{ priority: 10, exchange: 'mail.example.com' }
|
||||
]);
|
||||
validator.setTxtRecords('example.com', [
|
||||
['v=spf1 mx ~all']
|
||||
]);
|
||||
validator.setTxtRecords('default._domainkey.example.com', [
|
||||
['v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...']
|
||||
]);
|
||||
validator.setTxtRecords('_dmarc.example.com', [
|
||||
['v=DMARC1; p=none; rua=mailto:dmarc@example.com']
|
||||
]);
|
||||
|
||||
const config: IEmailDomainConfig = {
|
||||
domain: 'example.com',
|
||||
dnsMode: 'external-dns',
|
||||
dns: {
|
||||
external: {
|
||||
requiredRecords: ['MX', 'SPF', 'DKIM', 'DMARC']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateDomain(config);
|
||||
|
||||
// External DNS validation checks if records exist and provides instructions
|
||||
expect(result.valid).toEqual(true);
|
||||
expect(result.errors.length).toEqual(0);
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('DKIM Key Generation', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-dkim-generation');
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
|
||||
// Ensure keys directory exists
|
||||
const keysDir = plugins.path.join(testDir, 'keys');
|
||||
await plugins.fs.promises.mkdir(keysDir, { recursive: true });
|
||||
|
||||
const dkimCreator = new DKIMCreator(keysDir, storage);
|
||||
|
||||
// Generate DKIM keys
|
||||
await dkimCreator.handleDKIMKeysForDomain('test.example.com');
|
||||
|
||||
// Verify keys were created
|
||||
const keys = await dkimCreator.readDKIMKeys('test.example.com');
|
||||
expect(keys.privateKey).toBeTruthy();
|
||||
expect(keys.publicKey).toBeTruthy();
|
||||
expect(keys.privateKey).toContain('BEGIN RSA PRIVATE KEY');
|
||||
expect(keys.publicKey).toContain('BEGIN PUBLIC KEY');
|
||||
|
||||
// Get DNS record
|
||||
const dnsRecord = await dkimCreator.getDNSRecordForDomain('test.example.com');
|
||||
expect(dnsRecord.name).toEqual('mta._domainkey.test.example.com');
|
||||
expect(dnsRecord.type).toEqual('TXT');
|
||||
expect(dnsRecord.value).toContain('v=DKIM1');
|
||||
expect(dnsRecord.value).toContain('k=rsa');
|
||||
expect(dnsRecord.value).toContain('p=');
|
||||
|
||||
// Test key rotation
|
||||
const needsRotation = await dkimCreator.needsRotation('test.example.com', 'default', 0); // 0 days = always rotate
|
||||
expect(needsRotation).toEqual(true);
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('Domain Registry', async () => {
|
||||
// Test domain configurations
|
||||
const domains: IEmailDomainConfig[] = [
|
||||
{
|
||||
domain: 'simple.example.com',
|
||||
dnsMode: 'internal-dns'
|
||||
},
|
||||
{
|
||||
domain: 'configured.example.com',
|
||||
dnsMode: 'external-dns',
|
||||
dkim: {
|
||||
selector: 'custom',
|
||||
keySize: 4096
|
||||
},
|
||||
rateLimits: {
|
||||
outbound: {
|
||||
messagesPerMinute: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const defaults = {
|
||||
dnsMode: 'internal-dns' as const,
|
||||
dkim: {
|
||||
selector: 'default',
|
||||
keySize: 2048
|
||||
}
|
||||
};
|
||||
|
||||
const registry = new DomainRegistry(domains, defaults);
|
||||
|
||||
// Test simple domain (uses defaults)
|
||||
const simpleConfig = registry.getDomainConfig('simple.example.com');
|
||||
expect(simpleConfig).toBeTruthy();
|
||||
expect(simpleConfig?.dnsMode).toEqual('internal-dns');
|
||||
expect(simpleConfig?.dkim?.selector).toEqual('default');
|
||||
expect(simpleConfig?.dkim?.keySize).toEqual(2048);
|
||||
|
||||
// Test configured domain
|
||||
const configuredConfig = registry.getDomainConfig('configured.example.com');
|
||||
expect(configuredConfig).toBeTruthy();
|
||||
expect(configuredConfig?.dnsMode).toEqual('external-dns');
|
||||
expect(configuredConfig?.dkim?.selector).toEqual('custom');
|
||||
expect(configuredConfig?.dkim?.keySize).toEqual(4096);
|
||||
expect(configuredConfig?.rateLimits?.outbound?.messagesPerMinute).toEqual(100);
|
||||
|
||||
// Test non-existent domain
|
||||
const nonExistent = registry.getDomainConfig('nonexistent.example.com');
|
||||
expect(nonExistent).toEqual(undefined); // Returns undefined, not null
|
||||
|
||||
// Test getting all domains
|
||||
const allDomains = registry.getAllDomains();
|
||||
expect(allDomains.length).toEqual(2);
|
||||
expect(allDomains).toContain('simple.example.com');
|
||||
expect(allDomains).toContain('configured.example.com');
|
||||
});
|
||||
|
||||
tap.test('DNS Record Generation', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-dns-records');
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
|
||||
// Ensure keys directory exists
|
||||
const keysDir = plugins.path.join(testDir, 'keys');
|
||||
await plugins.fs.promises.mkdir(keysDir, { recursive: true });
|
||||
|
||||
const dkimCreator = new DKIMCreator(keysDir, storage);
|
||||
|
||||
// Generate DKIM keys first
|
||||
await dkimCreator.handleDKIMKeysForDomain('records.example.com');
|
||||
|
||||
// Test DNS record for domain
|
||||
const dkimRecord = await dkimCreator.getDNSRecordForDomain('records.example.com');
|
||||
|
||||
// Check DKIM record
|
||||
expect(dkimRecord).toBeTruthy();
|
||||
expect(dkimRecord.name).toContain('_domainkey.records.example.com');
|
||||
expect(dkimRecord.value).toContain('v=DKIM1');
|
||||
|
||||
// Note: The DnsManager doesn't have a generateDnsRecords method exposed
|
||||
// DNS records are handled internally or by the DNS server component
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,228 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.ts';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
|
||||
let dcRouter: DcRouter;
|
||||
|
||||
tap.test('should use traditional port forwarding when useSocketHandler is false', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
emailConfig: {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.test.local',
|
||||
domains: ['test.local'],
|
||||
routes: [],
|
||||
useSocketHandler: false // Traditional mode
|
||||
},
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Check that email server is created and listening on ports
|
||||
const emailServer = (dcRouter as any).emailServer;
|
||||
expect(emailServer).toBeDefined();
|
||||
|
||||
// Check SmartProxy routes are forward type
|
||||
const smartProxy = (dcRouter as any).smartProxy;
|
||||
const routes = smartProxy?.options?.routes || [];
|
||||
const emailRoutes = routes.filter((route: any) =>
|
||||
route.name?.includes('-route')
|
||||
);
|
||||
|
||||
emailRoutes.forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.target).toBeDefined();
|
||||
expect(route.action.target.host).toEqual('localhost');
|
||||
});
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('should use socket-handler mode when useSocketHandler is true', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
emailConfig: {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.test.local',
|
||||
domains: ['test.local'],
|
||||
routes: [],
|
||||
useSocketHandler: true // Socket-handler mode
|
||||
},
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Check that email server is created
|
||||
const emailServer = (dcRouter as any).emailServer;
|
||||
expect(emailServer).toBeDefined();
|
||||
|
||||
// Check SmartProxy routes are socket-handler type
|
||||
const smartProxy = (dcRouter as any).smartProxy;
|
||||
const routes = smartProxy?.options?.routes || [];
|
||||
const emailRoutes = routes.filter((route: any) =>
|
||||
route.name?.includes('-route')
|
||||
);
|
||||
|
||||
emailRoutes.forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('socket-handler');
|
||||
expect(route.action.socketHandler).toBeDefined();
|
||||
expect(typeof route.action.socketHandler).toEqual('function');
|
||||
});
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('should generate correct email routes for each port', async () => {
|
||||
const emailConfig = {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.test.local',
|
||||
domains: ['test.local'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
};
|
||||
|
||||
dcRouter = new DcRouter({ emailConfig });
|
||||
|
||||
// Access the private method to generate routes
|
||||
const emailRoutes = (dcRouter as any).generateEmailRoutes(emailConfig);
|
||||
|
||||
expect(emailRoutes.length).toEqual(3);
|
||||
|
||||
// Check SMTP route (port 25)
|
||||
const smtpRoute = emailRoutes.find((r: any) => r.name === 'smtp-route');
|
||||
expect(smtpRoute).toBeDefined();
|
||||
expect(smtpRoute.match.ports).toContain(25);
|
||||
expect(smtpRoute.action.type).toEqual('socket-handler');
|
||||
|
||||
// Check Submission route (port 587)
|
||||
const submissionRoute = emailRoutes.find((r: any) => r.name === 'submission-route');
|
||||
expect(submissionRoute).toBeDefined();
|
||||
expect(submissionRoute.match.ports).toContain(587);
|
||||
expect(submissionRoute.action.type).toEqual('socket-handler');
|
||||
|
||||
// Check SMTPS route (port 465)
|
||||
const smtpsRoute = emailRoutes.find((r: any) => r.name === 'smtps-route');
|
||||
expect(smtpsRoute).toBeDefined();
|
||||
expect(smtpsRoute.match.ports).toContain(465);
|
||||
expect(smtpsRoute.action.type).toEqual('socket-handler');
|
||||
});
|
||||
|
||||
tap.test('email socket handler should handle different ports correctly', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
emailConfig: {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.test.local',
|
||||
domains: ['test.local'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Test port 25 handler (plain SMTP)
|
||||
const port25Handler = (dcRouter as any).createMailSocketHandler(25);
|
||||
expect(port25Handler).toBeDefined();
|
||||
expect(typeof port25Handler).toEqual('function');
|
||||
|
||||
// Test port 465 handler (SMTPS - should wrap in TLS)
|
||||
const port465Handler = (dcRouter as any).createMailSocketHandler(465);
|
||||
expect(port465Handler).toBeDefined();
|
||||
expect(typeof port465Handler).toEqual('function');
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('email server handleSocket method should work', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
emailConfig: {
|
||||
ports: [25],
|
||||
hostname: 'mail.test.local',
|
||||
domains: ['test.local'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
const emailServer = (dcRouter as any).emailServer;
|
||||
expect(emailServer).toBeDefined();
|
||||
expect(emailServer.handleSocket).toBeDefined();
|
||||
expect(typeof emailServer.handleSocket).toEqual('function');
|
||||
|
||||
// Create a mock socket
|
||||
const mockSocket = new plugins.net.Socket();
|
||||
let socketDestroyed = false;
|
||||
|
||||
mockSocket.destroy = () => {
|
||||
socketDestroyed = true;
|
||||
};
|
||||
|
||||
// Test handleSocket
|
||||
try {
|
||||
await emailServer.handleSocket(mockSocket, 25);
|
||||
// It will fail because we don't have a real socket, but it should handle it gracefully
|
||||
} catch (error) {
|
||||
// Expected to error with mock socket
|
||||
}
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('should not create SMTP servers when useSocketHandler is true', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
emailConfig: {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.test.local',
|
||||
domains: ['test.local'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// The email server should not have any SMTP server instances
|
||||
const emailServer = (dcRouter as any).emailServer;
|
||||
expect(emailServer).toBeDefined();
|
||||
|
||||
// The servers array should be empty (no port binding)
|
||||
expect(emailServer.servers).toBeDefined();
|
||||
expect(emailServer.servers.length).toEqual(0);
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('TLS handling should differ between ports', async () => {
|
||||
const emailConfig = {
|
||||
ports: [25, 465],
|
||||
hostname: 'mail.test.local',
|
||||
domains: ['test.local'],
|
||||
routes: [],
|
||||
useSocketHandler: false // Use traditional mode to check TLS config
|
||||
};
|
||||
|
||||
dcRouter = new DcRouter({ emailConfig });
|
||||
|
||||
const emailRoutes = (dcRouter as any).generateEmailRoutes(emailConfig);
|
||||
|
||||
// Port 25 should use passthrough
|
||||
const smtpRoute = emailRoutes.find((r: any) => r.match.ports[0] === 25);
|
||||
expect(smtpRoute.action.tls.mode).toEqual('passthrough');
|
||||
|
||||
// Port 465 should use terminate
|
||||
const smtpsRoute = emailRoutes.find((r: any) => r.match.ports[0] === 465);
|
||||
expect(smtpsRoute.action.tls.mode).toEqual('terminate');
|
||||
expect(smtpsRoute.action.tls.certificate).toEqual('auto');
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,408 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as errors from '../ts/errors/index.ts';
|
||||
import {
|
||||
PlatformError,
|
||||
ValidationError,
|
||||
NetworkError,
|
||||
ResourceError,
|
||||
OperationError
|
||||
} from '../ts/errors/base.errors.ts';
|
||||
import {
|
||||
ErrorSeverity,
|
||||
ErrorCategory,
|
||||
ErrorRecoverability
|
||||
} from '../ts/errors/error.codes.ts';
|
||||
import {
|
||||
EmailServiceError,
|
||||
EmailTemplateError,
|
||||
EmailValidationError,
|
||||
EmailSendError,
|
||||
EmailReceiveError
|
||||
} from '../ts/errors/email.errors.ts';
|
||||
import {
|
||||
MtaConnectionError,
|
||||
MtaAuthenticationError,
|
||||
MtaDeliveryError,
|
||||
MtaConfigurationError
|
||||
} from '../ts/errors/mta.errors.ts';
|
||||
import {
|
||||
ErrorHandler
|
||||
} from '../ts/errors/error-handler.ts';
|
||||
|
||||
// Test base error classes
|
||||
tap.test('Base error classes should set properties correctly', async () => {
|
||||
const message = 'Test error message';
|
||||
const code = 'TEST_ERROR_CODE';
|
||||
const context = {
|
||||
component: 'TestComponent',
|
||||
operation: 'testOperation',
|
||||
data: { foo: 'bar' }
|
||||
};
|
||||
|
||||
// Test PlatformError
|
||||
const platformError = new PlatformError(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
|
||||
expect(platformError.message).toEqual(message);
|
||||
expect(platformError.code).toEqual(code);
|
||||
expect(platformError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||
expect(platformError.category).toEqual(ErrorCategory.OPERATION);
|
||||
expect(platformError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||
expect(platformError.context?.component).toEqual(context.component);
|
||||
expect(platformError.context?.operation).toEqual(context.operation);
|
||||
expect(platformError.context?.data?.foo).toEqual('bar');
|
||||
expect(platformError.name).toEqual('PlatformError');
|
||||
|
||||
// Test ValidationError
|
||||
const validationError = new ValidationError(message, code, context);
|
||||
expect(validationError.category).toEqual(ErrorCategory.VALIDATION);
|
||||
expect(validationError.severity).toEqual(ErrorSeverity.LOW);
|
||||
|
||||
// Test NetworkError
|
||||
const networkError = new NetworkError(message, code, context);
|
||||
expect(networkError.category).toEqual(ErrorCategory.CONNECTIVITY);
|
||||
expect(networkError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||
expect(networkError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||
|
||||
// Test ResourceError
|
||||
const resourceError = new ResourceError(message, code, context);
|
||||
expect(resourceError.category).toEqual(ErrorCategory.RESOURCE);
|
||||
});
|
||||
|
||||
// Test 7: Error withRetry() method
|
||||
tap.test('PlatformError withRetry creates new instance with retry info', async () => {
|
||||
const originalError = new EmailSendError('Send failed', {
|
||||
data: { someData: true }
|
||||
});
|
||||
|
||||
const retryError = originalError.withRetry(3, 1, 1000);
|
||||
|
||||
// Verify it's a new instance
|
||||
expect(retryError === originalError).toEqual(false);
|
||||
expect(retryError).toBeInstanceOf(EmailSendError);
|
||||
|
||||
// Verify original data is preserved
|
||||
expect(retryError.context?.data?.someData).toEqual(true);
|
||||
|
||||
// Verify retry info is added
|
||||
expect(retryError.context?.retry?.maxRetries).toEqual(3);
|
||||
expect(retryError.context?.retry?.currentRetry).toEqual(1);
|
||||
expect(retryError.context?.retry?.retryDelay).toEqual(1000);
|
||||
expect(retryError.context?.retry?.nextRetryAt).toBeTypeofNumber();
|
||||
});
|
||||
|
||||
// Test email error classes
|
||||
tap.test('Email error classes should be properly constructed', async () => {
|
||||
try {
|
||||
// Test EmailServiceError
|
||||
const emailServiceError = new EmailServiceError('Email service error', {
|
||||
component: 'EmailService',
|
||||
operation: 'sendEmail'
|
||||
});
|
||||
expect(emailServiceError.code).toEqual('EMAIL_SERVICE_ERROR');
|
||||
expect(emailServiceError.name).toEqual('EmailServiceError');
|
||||
|
||||
// Test EmailTemplateError
|
||||
const templateError = new EmailTemplateError('Template not found: welcome_email', {
|
||||
data: { templateId: 'welcome_email' }
|
||||
});
|
||||
expect(templateError.code).toEqual('EMAIL_TEMPLATE_ERROR');
|
||||
expect(templateError.context.data?.templateId).toEqual('welcome_email');
|
||||
|
||||
// Test EmailSendError with permanent flag
|
||||
const permanentError = EmailSendError.permanent(
|
||||
'Invalid recipient: user@example.com',
|
||||
{ data: { details: 'DNS not found', recipient: 'user@example.com' } }
|
||||
);
|
||||
expect(permanentError.code).toEqual('EMAIL_SEND_ERROR');
|
||||
expect(permanentError.isPermanent()).toEqual(true);
|
||||
expect(permanentError.context.data?.permanent).toEqual(true);
|
||||
|
||||
// Test EmailSendError with temporary flag and retry
|
||||
const tempError = EmailSendError.temporary(
|
||||
'Server busy',
|
||||
3,
|
||||
0,
|
||||
1000,
|
||||
{ data: { server: 'smtp.example.com' } }
|
||||
);
|
||||
expect(tempError.isPermanent()).toEqual(false);
|
||||
expect(tempError.context.data?.permanent).toEqual(false);
|
||||
expect(tempError.context.retry?.maxRetries).toEqual(3);
|
||||
expect(tempError.shouldRetry()).toEqual(true);
|
||||
} catch (error) {
|
||||
console.error('Test failed with error:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Test MTA error classes
|
||||
tap.test('MTA error classes should be properly constructed', async () => {
|
||||
try {
|
||||
// Test MtaConnectionError
|
||||
const dnsError = MtaConnectionError.dnsError('mail.example.com', new Error('DNS lookup failed'));
|
||||
expect(dnsError.code).toEqual('MTA_CONNECTION_ERROR');
|
||||
expect(dnsError.category).toEqual(ErrorCategory.CONNECTIVITY);
|
||||
expect(dnsError.context.data?.hostname).toEqual('mail.example.com');
|
||||
|
||||
// Test MtaTimeoutError via MtaConnectionError.timeout
|
||||
const timeoutError = MtaConnectionError.timeout('mail.example.com', 25, 30000);
|
||||
expect(timeoutError.code).toEqual('MTA_CONNECTION_ERROR');
|
||||
expect(timeoutError.context.data?.timeout).toEqual(30000);
|
||||
|
||||
// Test MtaAuthenticationError
|
||||
const authError = MtaAuthenticationError.invalidCredentials('mail.example.com', 'user@example.com');
|
||||
expect(authError.code).toEqual('MTA_AUTHENTICATION_ERROR');
|
||||
expect(authError.category).toEqual(ErrorCategory.AUTHENTICATION);
|
||||
expect(authError.context.data?.username).toEqual('user@example.com');
|
||||
|
||||
// Test MtaDeliveryError
|
||||
const permDeliveryError = MtaDeliveryError.permanent(
|
||||
'User unknown',
|
||||
'nonexistent@example.com',
|
||||
'550',
|
||||
'550 5.1.1 User unknown',
|
||||
{}
|
||||
);
|
||||
expect(permDeliveryError.code).toEqual('MTA_DELIVERY_ERROR');
|
||||
expect(permDeliveryError.isPermanent()).toEqual(true);
|
||||
expect(permDeliveryError.getRecipientAddress()).toEqual('nonexistent@example.com');
|
||||
expect(permDeliveryError.getStatusCode()).toEqual('550');
|
||||
|
||||
// Test temporary delivery error with retry
|
||||
const tempDeliveryError = MtaDeliveryError.temporary(
|
||||
'Mailbox temporarily unavailable',
|
||||
'user@example.com',
|
||||
'450',
|
||||
'450 4.2.1 Mailbox temporarily unavailable',
|
||||
3,
|
||||
1,
|
||||
5000
|
||||
);
|
||||
expect(tempDeliveryError.isPermanent()).toEqual(false);
|
||||
expect(tempDeliveryError.shouldRetry()).toEqual(true);
|
||||
expect(tempDeliveryError.context.retry?.currentRetry).toEqual(1);
|
||||
expect(tempDeliveryError.context.retry?.maxRetries).toEqual(3);
|
||||
} catch (error) {
|
||||
console.error('MTA test failed with error:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Test error handler utility
|
||||
tap.test('ErrorHandler should properly handle and format errors', async () => {
|
||||
// Configure error handler
|
||||
ErrorHandler.configure({
|
||||
logErrors: false, // Disable for testing
|
||||
includeStacksInProd: false,
|
||||
retry: {
|
||||
maxAttempts: 5,
|
||||
baseDelay: 100,
|
||||
maxDelay: 1000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
});
|
||||
|
||||
// Test converting regular Error to PlatformError
|
||||
const regularError = new Error('Something went wrong');
|
||||
const platformError = ErrorHandler.toPlatformError(
|
||||
regularError,
|
||||
'PLATFORM_OPERATION_ERROR',
|
||||
{ component: 'TestHandler' }
|
||||
);
|
||||
|
||||
expect(platformError).toBeInstanceOf(PlatformError);
|
||||
expect(platformError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||
expect(platformError.context?.component).toEqual('TestHandler');
|
||||
|
||||
// Test formatting error for API response
|
||||
const formattedError = ErrorHandler.formatErrorForResponse(platformError, true);
|
||||
expect(formattedError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||
expect(formattedError.message).toEqual('An unexpected error occurred.');
|
||||
expect(formattedError.details?.rawMessage).toEqual('Something went wrong');
|
||||
|
||||
// Test executing a function with error handling
|
||||
let executed = false;
|
||||
try {
|
||||
await ErrorHandler.execute(async () => {
|
||||
executed = true;
|
||||
throw new Error('Execution failed');
|
||||
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
|
||||
expect(error.context.operation).toEqual('testExecution');
|
||||
}
|
||||
expect(executed).toEqual(true);
|
||||
|
||||
// Test executeWithRetry successful after retries
|
||||
let attempts = 0;
|
||||
const result = await ErrorHandler.executeWithRetry(
|
||||
async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('Temporary failure');
|
||||
}
|
||||
return 'success';
|
||||
},
|
||||
'TEST_RETRY_ERROR',
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseDelay: 10, // Use small delay for tests
|
||||
retryableErrorPatterns: [/Temporary failure/], // Add pattern to make error retryable
|
||||
onRetry: (error, attempt, delay) => {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(attempt).toBeGreaterThan(0);
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual('success');
|
||||
expect(attempts).toEqual(3);
|
||||
|
||||
// Test executeWithRetry that fails after max attempts
|
||||
attempts = 0;
|
||||
try {
|
||||
await ErrorHandler.executeWithRetry(
|
||||
async () => {
|
||||
attempts++;
|
||||
throw new Error('Persistent failure');
|
||||
},
|
||||
'TEST_RETRY_ERROR',
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 10,
|
||||
retryableErrorPatterns: [/Persistent failure/] // Make error retryable so it tries all attempts
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(attempts).toEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
// Test retry utilities
|
||||
tap.test('Error retry utilities should work correctly', async () => {
|
||||
let attempts = 0;
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
await errors.retry(
|
||||
async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('Temporary error');
|
||||
}
|
||||
return 'success';
|
||||
},
|
||||
{
|
||||
maxRetries: 5,
|
||||
initialDelay: 20,
|
||||
backoffFactor: 1.5,
|
||||
retryableErrors: [/Temporary/]
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
// Should not reach here
|
||||
expect(false).toEqual(true);
|
||||
}
|
||||
|
||||
expect(attempts).toEqual(3);
|
||||
|
||||
// Test retry with non-retryable error
|
||||
attempts = 0;
|
||||
try {
|
||||
await errors.retry(
|
||||
async () => {
|
||||
attempts++;
|
||||
throw new Error('Critical error');
|
||||
},
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Temporary/] // Won't match "Critical"
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual('Critical error');
|
||||
expect(attempts).toEqual(1); // Should only attempt once
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function that will reject first n times, then resolve
|
||||
interface FlakyFunction {
|
||||
(failTimes: number, result?: any): Promise<any>;
|
||||
counter: number;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const flaky: FlakyFunction = Object.assign(
|
||||
async function (failTimes: number, result: any = 'success'): Promise<any> {
|
||||
if (flaky.counter < failTimes) {
|
||||
flaky.counter++;
|
||||
throw new Error(`Flaky failure ${flaky.counter}`);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{
|
||||
counter: 0,
|
||||
reset: () => { flaky.counter = 0; }
|
||||
}
|
||||
);
|
||||
|
||||
// Test error wrapping and retry combination
|
||||
tap.test('Error handling can be combined with retry for robust operations', async () => {
|
||||
// Reset counter for the test
|
||||
flaky.reset();
|
||||
|
||||
// Create a wrapped version of the flaky function
|
||||
const wrapped = errors.withErrorHandling(
|
||||
() => flaky(2, 'wrapped success'),
|
||||
'TEST_WRAPPED_ERROR',
|
||||
{ component: 'TestComponent' }
|
||||
);
|
||||
|
||||
// Execute with retry
|
||||
const result = await errors.retry(
|
||||
wrapped,
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Flaky failure/]
|
||||
}
|
||||
);
|
||||
expect(result).toEqual('wrapped success');
|
||||
expect(flaky.counter).toEqual(2);
|
||||
|
||||
// Reset and test failure case
|
||||
flaky.reset();
|
||||
|
||||
try {
|
||||
await errors.retry(
|
||||
() => flaky(5, 'never reached'),
|
||||
{
|
||||
maxRetries: 2, // Only retry twice, but we need 5 attempts to succeed
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Flaky failure/] // Add pattern to make it retry
|
||||
}
|
||||
);
|
||||
// Should not reach here
|
||||
expect(false).toEqual(true);
|
||||
} catch (error) {
|
||||
expect(error.message).toContain('Flaky failure');
|
||||
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,313 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import * as paths from '../ts/paths.ts';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.ts';
|
||||
import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.ts';
|
||||
import { BounceManager } from '../ts/mail/core/classes.bouncemanager.ts';
|
||||
import { EmailRouter } from '../ts/mail/routing/classes.email.router.ts';
|
||||
import type { IEmailRoute } from '../ts/mail/routing/interfaces.ts';
|
||||
|
||||
tap.test('Storage Persistence Across Restarts', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-integration-persistence');
|
||||
|
||||
// Phase 1: Create storage and write data
|
||||
{
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
|
||||
// Write some test data
|
||||
await storage.set('/test/key1', 'value1');
|
||||
await storage.setJSON('/test/json', { data: 'test', count: 42 });
|
||||
await storage.set('/other/key2', 'value2');
|
||||
}
|
||||
|
||||
// Phase 2: Create new instance and verify data persists
|
||||
{
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
|
||||
// Verify data persists
|
||||
const value1 = await storage.get('/test/key1');
|
||||
expect(value1).toEqual('value1');
|
||||
|
||||
const jsonData = await storage.getJSON('/test/json');
|
||||
expect(jsonData).toEqual({ data: 'test', count: 42 });
|
||||
|
||||
const value2 = await storage.get('/other/key2');
|
||||
expect(value2).toEqual('value2');
|
||||
|
||||
// Test list operation
|
||||
const testKeys = await storage.list('/test');
|
||||
expect(testKeys.length).toEqual(2);
|
||||
expect(testKeys).toContain('/test/key1');
|
||||
expect(testKeys).toContain('/test/json');
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('DKIM Storage Integration', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-integration-dkim');
|
||||
const keysDir = plugins.path.join(testDir, 'keys');
|
||||
|
||||
// Phase 1: Generate DKIM keys with storage
|
||||
{
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
const dkimCreator = new DKIMCreator(keysDir, storage);
|
||||
|
||||
await dkimCreator.handleDKIMKeysForDomain('storage.example.com');
|
||||
|
||||
// Verify keys exist
|
||||
const keys = await dkimCreator.readDKIMKeys('storage.example.com');
|
||||
expect(keys.privateKey).toBeTruthy();
|
||||
expect(keys.publicKey).toBeTruthy();
|
||||
}
|
||||
|
||||
// Phase 2: New instance should find keys in storage
|
||||
{
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
const dkimCreator = new DKIMCreator(keysDir, storage);
|
||||
|
||||
// Keys should be loaded from storage
|
||||
const keys = await dkimCreator.readDKIMKeys('storage.example.com');
|
||||
expect(keys.privateKey).toBeTruthy();
|
||||
expect(keys.publicKey).toBeTruthy();
|
||||
expect(keys.privateKey).toContain('BEGIN RSA PRIVATE KEY');
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('Bounce Manager Storage Integration', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-integration-bounce');
|
||||
|
||||
// Phase 1: Add to suppression list with storage
|
||||
{
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
const bounceManager = new BounceManager({
|
||||
storageManager: storage
|
||||
});
|
||||
|
||||
// Add emails to suppression list
|
||||
bounceManager.addToSuppressionList('bounce1@example.com', 'Hard bounce: invalid_recipient');
|
||||
bounceManager.addToSuppressionList('bounce2@example.com', 'Soft bounce: temporary', Date.now() + 3600000);
|
||||
|
||||
// Verify suppression
|
||||
expect(bounceManager.isEmailSuppressed('bounce1@example.com')).toEqual(true);
|
||||
expect(bounceManager.isEmailSuppressed('bounce2@example.com')).toEqual(true);
|
||||
}
|
||||
|
||||
// Wait a moment to ensure async save completes
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Phase 2: New instance should load suppression list from storage
|
||||
{
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
const bounceManager = new BounceManager({
|
||||
storageManager: storage
|
||||
});
|
||||
|
||||
// Wait for async load
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify persistence
|
||||
expect(bounceManager.isEmailSuppressed('bounce1@example.com')).toEqual(true);
|
||||
expect(bounceManager.isEmailSuppressed('bounce2@example.com')).toEqual(true);
|
||||
expect(bounceManager.isEmailSuppressed('notbounced@example.com')).toEqual(false);
|
||||
|
||||
// Check suppression info
|
||||
const info1 = bounceManager.getSuppressionInfo('bounce1@example.com');
|
||||
expect(info1).toBeTruthy();
|
||||
expect(info1?.reason).toContain('Hard bounce');
|
||||
expect(info1?.expiresAt).toBeUndefined(); // Permanent
|
||||
|
||||
const info2 = bounceManager.getSuppressionInfo('bounce2@example.com');
|
||||
expect(info2).toBeTruthy();
|
||||
expect(info2?.reason).toContain('Soft bounce');
|
||||
expect(info2?.expiresAt).toBeGreaterThan(Date.now());
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('Email Router Storage Integration', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-integration-router');
|
||||
|
||||
const testRoutes: IEmailRoute[] = [
|
||||
{
|
||||
name: 'test-route-1',
|
||||
match: { recipients: '*@test.com' },
|
||||
action: { type: 'forward', forward: { host: 'test.server.com', port: 25 } },
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'test-route-2',
|
||||
match: { senders: '*@internal.com' },
|
||||
action: { type: 'process', process: { scan: true, dkim: true } },
|
||||
priority: 50
|
||||
}
|
||||
];
|
||||
|
||||
// Phase 1: Save routes with storage
|
||||
{
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
const router = new EmailRouter([], {
|
||||
storageManager: storage,
|
||||
persistChanges: true
|
||||
});
|
||||
|
||||
// Add routes
|
||||
await router.addRoute(testRoutes[0]);
|
||||
await router.addRoute(testRoutes[1]);
|
||||
|
||||
// Verify routes
|
||||
const routes = router.getRoutes();
|
||||
expect(routes.length).toEqual(2);
|
||||
expect(routes[0].name).toEqual('test-route-1'); // Higher priority first
|
||||
expect(routes[1].name).toEqual('test-route-2');
|
||||
}
|
||||
|
||||
// Phase 2: New instance should load routes from storage
|
||||
{
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
const router = new EmailRouter([], {
|
||||
storageManager: storage,
|
||||
persistChanges: true
|
||||
});
|
||||
|
||||
// Wait for async load
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Manually load routes (since constructor load is fire-and-forget)
|
||||
await router.loadRoutes({ replace: true });
|
||||
|
||||
// Verify persistence
|
||||
const routes = router.getRoutes();
|
||||
expect(routes.length).toEqual(2);
|
||||
expect(routes[0].name).toEqual('test-route-1');
|
||||
expect(routes[0].priority).toEqual(100);
|
||||
expect(routes[1].name).toEqual('test-route-2');
|
||||
expect(routes[1].priority).toEqual(50);
|
||||
|
||||
// Test route retrieval
|
||||
const route1 = router.getRoute('test-route-1');
|
||||
expect(route1).toBeTruthy();
|
||||
expect(route1?.match.recipients).toEqual('*@test.com');
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('Storage Backend Switching', async () => {
|
||||
const testDir = plugins.path.join(paths.dataDir, '.test-integration-switching');
|
||||
const testData = { key: 'value', nested: { data: true } };
|
||||
|
||||
// Phase 1: Start with memory storage
|
||||
const memoryStore = new Map<string, string>();
|
||||
{
|
||||
const storage = new StorageManager(); // Memory backend
|
||||
await storage.setJSON('/switch/test', testData);
|
||||
|
||||
// Verify it's in memory
|
||||
expect(storage.getBackend()).toEqual('memory');
|
||||
}
|
||||
|
||||
// Phase 2: Switch to custom backend
|
||||
{
|
||||
const storage = new StorageManager({
|
||||
readFunction: async (key) => memoryStore.get(key) || null,
|
||||
writeFunction: async (key, value) => { memoryStore.set(key, value); }
|
||||
});
|
||||
|
||||
// Write data
|
||||
await storage.setJSON('/switch/test', testData);
|
||||
|
||||
// Verify backend
|
||||
expect(storage.getBackend()).toEqual('custom');
|
||||
expect(memoryStore.has('/switch/test')).toEqual(true);
|
||||
}
|
||||
|
||||
// Phase 3: Switch to filesystem
|
||||
{
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
|
||||
// Migrate data from custom backend
|
||||
const dataStr = memoryStore.get('/switch/test');
|
||||
if (dataStr) {
|
||||
await storage.set('/switch/test', dataStr);
|
||||
}
|
||||
|
||||
// Verify data migrated
|
||||
const data = await storage.getJSON('/switch/test');
|
||||
expect(data).toEqual(testData);
|
||||
expect(storage.getBackend()).toEqual('filesystem'); // fsPath is now properly reported as filesystem
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('Data Migration Between Backends', async () => {
|
||||
const testDir1 = plugins.path.join(paths.dataDir, '.test-migration-source');
|
||||
const testDir2 = plugins.path.join(paths.dataDir, '.test-migration-dest');
|
||||
|
||||
// Create test data structure
|
||||
const testData = {
|
||||
'/config/app': JSON.stringify({ name: 'test-app', version: '1.0.0' }),
|
||||
'/config/database': JSON.stringify({ host: 'localhost', port: 5432 }),
|
||||
'/data/users/1': JSON.stringify({ id: 1, name: 'User One' }),
|
||||
'/data/users/2': JSON.stringify({ id: 2, name: 'User Two' }),
|
||||
'/logs/app.log': 'Log entry 1\nLog entry 2\nLog entry 3'
|
||||
};
|
||||
|
||||
// Phase 1: Populate source storage
|
||||
{
|
||||
const source = new StorageManager({ fsPath: testDir1 });
|
||||
|
||||
for (const [key, value] of Object.entries(testData)) {
|
||||
await source.set(key, value);
|
||||
}
|
||||
|
||||
// Verify data written
|
||||
const keys = await source.list('/');
|
||||
expect(keys.length).toBeGreaterThanOrEqual(5);
|
||||
}
|
||||
|
||||
// Phase 2: Migrate to destination
|
||||
{
|
||||
const source = new StorageManager({ fsPath: testDir1 });
|
||||
const dest = new StorageManager({ fsPath: testDir2 });
|
||||
|
||||
// List all keys from source
|
||||
const allKeys = await source.list('/');
|
||||
|
||||
// Migrate each key
|
||||
for (const key of allKeys) {
|
||||
const value = await source.get(key);
|
||||
if (value !== null) {
|
||||
await dest.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify migration
|
||||
for (const [key, expectedValue] of Object.entries(testData)) {
|
||||
const value = await dest.get(key);
|
||||
expect(value).toEqual(expectedValue);
|
||||
}
|
||||
|
||||
// Verify structure preserved
|
||||
const configKeys = await dest.list('/config');
|
||||
expect(configKeys.length).toEqual(2);
|
||||
|
||||
const userKeys = await dest.list('/data/users');
|
||||
expect(userKeys.length).toEqual(2);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await plugins.fs.promises.rm(testDir1, { recursive: true, force: true }).catch(() => {});
|
||||
await plugins.fs.promises.rm(testDir2, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,75 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
// SzPlatformService doesn't exist in codebase - using DcRouter instead for integration tests
|
||||
import DcRouter from '../ts/classes.dcrouter.ts';
|
||||
import { BounceManager } from '../ts/mail/core/classes.bouncemanager.ts';
|
||||
import { smtpClientMod } from '../ts/mail/delivery/index.ts';
|
||||
import { SmtpServer } from '../ts/mail/delivery/smtpserver/smtp-server.ts';
|
||||
|
||||
// Test the new integration architecture
|
||||
tap.test('should be able to create an SMTP server', async (tools) => {
|
||||
// Create an SMTP server
|
||||
const smtpServer = new SmtpServer({
|
||||
options: {
|
||||
port: 10025,
|
||||
hostname: 'test.example.com',
|
||||
key: '',
|
||||
cert: ''
|
||||
}
|
||||
});
|
||||
|
||||
// Verify it was created properly
|
||||
expect(smtpServer).toBeTruthy();
|
||||
expect(smtpServer.options.port).toEqual(10025);
|
||||
expect(smtpServer.options.hostname).toEqual('test.example.com');
|
||||
});
|
||||
|
||||
|
||||
tap.test('DcRouter should support email configuration', async (tools) => {
|
||||
// Create a DcRouter with email config
|
||||
const dcRouter = new DcRouter({
|
||||
emailConfig: {
|
||||
useEmail: true,
|
||||
domainRules: [{
|
||||
// name: 'test-rule', // not part of IDomainRule
|
||||
match: {
|
||||
senderPattern: '.*@test.com',
|
||||
},
|
||||
actions: []
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
// Verify it was created properly
|
||||
expect(dcRouter).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('SMTP client should be able to connect to SMTP server', async (tools) => {
|
||||
// Create an SMTP client
|
||||
const options = {
|
||||
host: 'smtp.test.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: 'test@example.com',
|
||||
pass: 'testpass'
|
||||
},
|
||||
connectionTimeout: 5000,
|
||||
socketTimeout: 5000
|
||||
};
|
||||
|
||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
||||
|
||||
// Verify it was created properly
|
||||
expect(smtpClient).toBeTruthy();
|
||||
// Since options are not exposed, just verify the client was created
|
||||
expect(typeof smtpClient.sendMail).toEqual('function');
|
||||
expect(typeof smtpClient.getPoolStatus).toEqual('function');
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
// Export for tapbundle execution
|
||||
export default tap.start();
|
||||
@@ -1,323 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import * as paths from '../ts/paths.ts';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.ts';
|
||||
|
||||
// Cleanup any temporary test data
|
||||
const cleanupTestData = () => {
|
||||
const warmupDataPath = plugins.path.join(paths.dataDir, 'warmup');
|
||||
if (plugins.fs.existsSync(warmupDataPath)) {
|
||||
// Remove the directory recursively using fs instead of smartfile
|
||||
plugins.fs.rmSync(warmupDataPath, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset the singleton instance between tests
|
||||
const resetSingleton = () => {
|
||||
// @ts-ignore - accessing private static field for testing
|
||||
IPWarmupManager.instance = null;
|
||||
};
|
||||
|
||||
// Before running any tests
|
||||
tap.test('setup', async () => {
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
// Test initialization of IPWarmupManager
|
||||
tap.test('should initialize IPWarmupManager with default settings', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance();
|
||||
|
||||
expect(ipWarmupManager).toBeTruthy();
|
||||
expect(typeof ipWarmupManager.getBestIPForSending).toEqual('function');
|
||||
expect(typeof ipWarmupManager.canSendMoreToday).toEqual('function');
|
||||
expect(typeof ipWarmupManager.getStageCount).toEqual('function');
|
||||
expect(typeof ipWarmupManager.setActiveAllocationPolicy).toEqual('function');
|
||||
});
|
||||
|
||||
// Test initialization with custom settings
|
||||
tap.test('should initialize IPWarmupManager with custom settings', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com', 'test.com'],
|
||||
fallbackPercentage: 75
|
||||
});
|
||||
|
||||
// Test setting allocation policy
|
||||
ipWarmupManager.setActiveAllocationPolicy('roundRobin');
|
||||
|
||||
// Get best IP for sending
|
||||
const bestIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// Check if we can send more today
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday('192.168.1.1');
|
||||
|
||||
// Check stage count
|
||||
const stageCount = ipWarmupManager.getStageCount();
|
||||
expect(typeof stageCount).toEqual('number');
|
||||
});
|
||||
|
||||
// Test IP allocation policies
|
||||
tap.test('should allocate IPs using balanced policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
// Use getBestIPForSending multiple times and check if all IPs are used
|
||||
const usedIPs = new Set();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const ip = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
if (ip) usedIPs.add(ip);
|
||||
}
|
||||
|
||||
// We should use at least 2 different IPs with balanced policy
|
||||
expect(usedIPs.size >= 2).toEqual(true);
|
||||
});
|
||||
|
||||
// Test round robin allocation policy
|
||||
tap.test('should allocate IPs using round robin policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
ipWarmupManager.setActiveAllocationPolicy('roundRobin');
|
||||
|
||||
// First few IPs should rotate through the available IPs
|
||||
const firstIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const secondIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const thirdIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// Round robin should give us different IPs for consecutive calls
|
||||
expect(firstIP !== secondIP).toEqual(true);
|
||||
|
||||
// With 3 IPs, the fourth call should cycle back to one of the IPs
|
||||
const fourthIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// Check that the fourth IP is one of the 3 valid IPs
|
||||
expect(['192.168.1.1', '192.168.1.2', '192.168.1.3'].includes(fourthIP)).toEqual(true);
|
||||
});
|
||||
|
||||
// Test dedicated domain allocation policy
|
||||
tap.test('should allocate IPs using dedicated domain policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com', 'other.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
ipWarmupManager.setActiveAllocationPolicy('dedicated');
|
||||
|
||||
// Instead of mapDomainToIP which doesn't exist, we'll simulate domain mapping
|
||||
// by making dedicated calls per domain - we can't call the internal method directly
|
||||
|
||||
// Each domain should get its dedicated IP
|
||||
const exampleIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@gmail.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const testIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@test.com',
|
||||
to: ['recipient@gmail.com'],
|
||||
domain: 'test.com'
|
||||
});
|
||||
|
||||
const otherIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@other.com',
|
||||
to: ['recipient@gmail.com'],
|
||||
domain: 'other.com'
|
||||
});
|
||||
|
||||
// Since we're not actually mapping domains to IPs, we can only test if they return valid IPs
|
||||
// The original assertions have been modified since we can't guarantee which IP will be returned
|
||||
expect(exampleIP).toBeTruthy();
|
||||
expect(testIP).toBeTruthy();
|
||||
expect(otherIP).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test daily sending limits
|
||||
tap.test('should enforce daily sending limits', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1'],
|
||||
targetDomains: ['example.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
// Override the warmup stage for testing
|
||||
// @ts-ignore - accessing private method for testing
|
||||
ipWarmupManager.warmupStatuses.set('192.168.1.1', {
|
||||
ipAddress: '192.168.1.1',
|
||||
isActive: true,
|
||||
currentStage: 1,
|
||||
startDate: new Date(),
|
||||
currentStageStartDate: new Date(),
|
||||
targetCompletionDate: new Date(),
|
||||
currentDailyAllocation: 5,
|
||||
sentInCurrentStage: 0,
|
||||
totalSent: 0,
|
||||
dailyStats: [],
|
||||
metrics: {
|
||||
openRate: 0,
|
||||
bounceRate: 0,
|
||||
complaintRate: 0
|
||||
}
|
||||
});
|
||||
|
||||
// Set a very low daily limit for testing
|
||||
// @ts-ignore - accessing private method for testing
|
||||
ipWarmupManager.config.stages = [
|
||||
{ stage: 1, maxDailyVolume: 5, durationDays: 5, targetMetrics: { maxBounceRate: 8, minOpenRate: 15 } }
|
||||
];
|
||||
|
||||
// First pass: should be able to get an IP
|
||||
const ip = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(ip === '192.168.1.1').toEqual(true);
|
||||
|
||||
// Record 5 sends to reach the daily limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ipWarmupManager.recordSend('192.168.1.1');
|
||||
}
|
||||
|
||||
// Check if we can send more today
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday('192.168.1.1');
|
||||
expect(canSendMore).toEqual(false);
|
||||
|
||||
// After reaching limit, getBestIPForSending should return null
|
||||
// since there are no available IPs
|
||||
const sixthIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(sixthIP === null).toEqual(true);
|
||||
});
|
||||
|
||||
// Test recording sends
|
||||
tap.test('should record send events correctly', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com'],
|
||||
});
|
||||
|
||||
// Set allocation policy
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
// Get an IP for sending
|
||||
const ip = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// If we got an IP, record some sends
|
||||
if (ip) {
|
||||
// Record a few sends
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ipWarmupManager.recordSend(ip);
|
||||
}
|
||||
|
||||
// Check if we can still send more
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday(ip);
|
||||
expect(typeof canSendMore).toEqual('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
// Test that DedicatedDomainPolicy assigns IPs correctly
|
||||
tap.test('should assign IPs using dedicated domain policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com', 'other.com']
|
||||
});
|
||||
|
||||
// Set allocation policy to dedicated domains
|
||||
ipWarmupManager.setActiveAllocationPolicy('dedicated');
|
||||
|
||||
// Check allocation by querying for different domains
|
||||
const ip1 = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const ip2 = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@test.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'test.com'
|
||||
});
|
||||
|
||||
// If we got IPs, they should be consistently assigned
|
||||
if (ip1 && ip2) {
|
||||
// Requesting the same domain again should return the same IP
|
||||
const ip1again = ipWarmupManager.getBestIPForSending({
|
||||
from: 'another@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(ip1again === ip1).toEqual(true);
|
||||
}
|
||||
});
|
||||
|
||||
// After all tests, clean up
|
||||
tap.test('cleanup', async () => {
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,130 +0,0 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.ts';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.ts';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login with admin credentials and receive JWT', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity).toHaveProperty('jwt');
|
||||
expect(response.identity).toHaveProperty('userId');
|
||||
expect(response.identity).toHaveProperty('name');
|
||||
expect(response.identity).toHaveProperty('expiresAt');
|
||||
expect(response.identity).toHaveProperty('role');
|
||||
expect(response.identity.role).toEqual('admin');
|
||||
|
||||
identity = response.identity;
|
||||
console.log('JWT:', identity.jwt);
|
||||
});
|
||||
|
||||
tap.test('should verify valid JWT identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should reject invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity: {
|
||||
...identity,
|
||||
jwt: 'invalid.jwt.token'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should verify JWT matches identity data', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
// The response should contain the same identity data as the JWT
|
||||
const response = await verifyRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should handle logout', async () => {
|
||||
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLogout'
|
||||
);
|
||||
|
||||
const response = await logoutRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('success');
|
||||
expect(response.success).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should reject wrong credentials', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
let errorOccurred = false;
|
||||
try {
|
||||
await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'wrongpassword'
|
||||
});
|
||||
} catch (error) {
|
||||
errorOccurred = true;
|
||||
// TypedResponseError is thrown
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
|
||||
expect(errorOccurred).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,66 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import * as paths from '../ts/paths.ts';
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.ts';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.ts';
|
||||
|
||||
/**
|
||||
* Basic test to check if our integrated classes work correctly
|
||||
*/
|
||||
tap.test('verify that SenderReputationMonitor and IPWarmupManager are functioning', async (tools) => {
|
||||
// Create instances of both classes
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com']
|
||||
});
|
||||
|
||||
// Test SenderReputationMonitor
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
|
||||
const reputationData = reputationMonitor.getReputationData('example.com');
|
||||
const summary = reputationMonitor.getReputationSummary();
|
||||
|
||||
// Basic checks
|
||||
expect(reputationData).toBeTruthy();
|
||||
expect(summary.length).toBeGreaterThan(0);
|
||||
|
||||
// Add and remove domains
|
||||
reputationMonitor.addDomain('test.com');
|
||||
reputationMonitor.removeDomain('test.com');
|
||||
|
||||
// Test IPWarmupManager
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
const bestIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
if (bestIP) {
|
||||
ipWarmupManager.recordSend(bestIP);
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday(bestIP);
|
||||
expect(canSendMore !== undefined).toEqual(true);
|
||||
}
|
||||
|
||||
const stageCount = ipWarmupManager.getStageCount();
|
||||
expect(stageCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op - just to make sure everything is cleaned up properly
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,83 +0,0 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.ts';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.ts';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should respond to health status request', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
const response = await healthRequest.fire({
|
||||
detailed: false
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
expect(response.health.services).toHaveProperty('OpsServer');
|
||||
});
|
||||
|
||||
tap.test('should respond to server statistics request', async () => {
|
||||
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getServerStatistics'
|
||||
);
|
||||
|
||||
const response = await statsRequest.fire({
|
||||
includeHistory: false
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('stats');
|
||||
expect(response.stats).toHaveProperty('uptime');
|
||||
expect(response.stats).toHaveProperty('cpuUsage');
|
||||
expect(response.stats).toHaveProperty('memoryUsage');
|
||||
});
|
||||
|
||||
tap.test('should respond to configuration request', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
const response = await configRequest.fire({});
|
||||
|
||||
expect(response).toHaveProperty('config');
|
||||
expect(response.config).toHaveProperty('email');
|
||||
expect(response.config).toHaveProperty('dns');
|
||||
expect(response.config).toHaveProperty('proxy');
|
||||
expect(response.config).toHaveProperty('security');
|
||||
});
|
||||
|
||||
tap.test('should handle log retrieval request', async () => {
|
||||
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getRecentLogs'
|
||||
);
|
||||
|
||||
const response = await logsRequest.fire({
|
||||
limit: 10
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('logs');
|
||||
expect(response).toHaveProperty('total');
|
||||
expect(response).toHaveProperty('hasMore');
|
||||
expect(response.logs).toBeArray();
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,115 +0,0 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.ts';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.ts';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
console.log('Admin logged in with JWT');
|
||||
});
|
||||
|
||||
tap.test('should allow admin to update configuration', async () => {
|
||||
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'updateConfiguration'
|
||||
);
|
||||
|
||||
const response = await updateRequest.fire({
|
||||
identity: adminIdentity,
|
||||
section: 'security',
|
||||
config: {
|
||||
rateLimit: true,
|
||||
spamDetection: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('updated');
|
||||
expect(response.updated).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should reject configuration update without identity', async () => {
|
||||
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'updateConfiguration'
|
||||
);
|
||||
|
||||
try {
|
||||
await updateRequest.fire({
|
||||
section: 'security',
|
||||
config: {
|
||||
rateLimit: false
|
||||
}
|
||||
});
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
console.log('Successfully rejected request without identity');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should reject configuration update with invalid JWT', async () => {
|
||||
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'updateConfiguration'
|
||||
);
|
||||
|
||||
try {
|
||||
await updateRequest.fire({
|
||||
identity: {
|
||||
...adminIdentity,
|
||||
jwt: 'invalid.jwt.token'
|
||||
},
|
||||
section: 'security',
|
||||
config: {
|
||||
rateLimit: false
|
||||
}
|
||||
});
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
console.log('Successfully rejected request with invalid JWT');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should allow access to public endpoints without auth', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
// No identity provided
|
||||
const response = await healthRequest.fire({});
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
console.log('Public endpoint accessible without auth');
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,262 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import * as paths from '../ts/paths.ts';
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.ts';
|
||||
|
||||
// Set NODE_ENV to test to prevent loading persisted data
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Cleanup any temporary test data
|
||||
const cleanupTestData = () => {
|
||||
const reputationDataPath = plugins.path.join(paths.dataDir, 'reputation');
|
||||
if (plugins.fs.existsSync(reputationDataPath)) {
|
||||
// Remove the directory recursively using fs instead of smartfile
|
||||
plugins.fs.rmSync(reputationDataPath, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset the singleton instance between tests
|
||||
const resetSingleton = () => {
|
||||
// @ts-ignore - accessing private static field for testing
|
||||
SenderReputationMonitor.instance = null;
|
||||
|
||||
// Clean up any timeout to prevent race conditions
|
||||
const activeSendReputationMonitors = Array.from(Object.values(global))
|
||||
.filter((item: any) => item && typeof item === 'object' && item._idleTimeout)
|
||||
.filter((item: any) =>
|
||||
item._onTimeout &&
|
||||
item._onTimeout.toString &&
|
||||
item._onTimeout.toString().includes('updateAllDomainMetrics'));
|
||||
|
||||
// Clear any active timeouts to prevent race conditions
|
||||
activeSendReputationMonitors.forEach((timer: any) => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
};
|
||||
|
||||
// Before running any tests
|
||||
tap.test('setup', async () => {
|
||||
resetSingleton();
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
// Test initialization of SenderReputationMonitor
|
||||
tap.test('should initialize SenderReputationMonitor with default settings', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance();
|
||||
|
||||
expect(reputationMonitor).toBeTruthy();
|
||||
// Check if the object has the expected methods
|
||||
expect(typeof reputationMonitor.recordSendEvent).toEqual('function');
|
||||
expect(typeof reputationMonitor.getReputationData).toEqual('function');
|
||||
expect(typeof reputationMonitor.getReputationSummary).toEqual('function');
|
||||
});
|
||||
|
||||
// Test initialization with custom settings
|
||||
tap.test('should initialize SenderReputationMonitor with custom settings', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: false, // Disable automatic updates to prevent race conditions
|
||||
domains: ['example.com', 'test.com'],
|
||||
updateFrequency: 12 * 60 * 60 * 1000, // 12 hours
|
||||
alertThresholds: {
|
||||
minReputationScore: 80,
|
||||
maxComplaintRate: 0.05
|
||||
}
|
||||
});
|
||||
|
||||
// Test adding domains
|
||||
reputationMonitor.addDomain('newdomain.com');
|
||||
|
||||
// Test retrieving reputation data
|
||||
const data = reputationMonitor.getReputationData('example.com');
|
||||
expect(data).toBeTruthy();
|
||||
expect(data.domain).toEqual('example.com');
|
||||
});
|
||||
|
||||
// Test recording and tracking send events
|
||||
tap.test('should record send events and update metrics', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: false, // Disable automatic updates to prevent race conditions
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record a series of events
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: true, count: 3 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: false, count: 2 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'complaint', count: 1 });
|
||||
|
||||
// Check metrics
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(metrics.volume.sent).toEqual(100);
|
||||
expect(metrics.volume.delivered).toEqual(95);
|
||||
expect(metrics.volume.hardBounces).toEqual(3);
|
||||
expect(metrics.volume.softBounces).toEqual(2);
|
||||
expect(metrics.complaints.total).toEqual(1);
|
||||
});
|
||||
|
||||
// Test reputation score calculation
|
||||
tap.test('should calculate reputation scores correctly', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: false, // Disable automatic updates to prevent race conditions
|
||||
domains: ['high.com', 'medium.com', 'low.com']
|
||||
});
|
||||
|
||||
// Record events for different domains
|
||||
reputationMonitor.recordSendEvent('high.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('high.com', { type: 'delivered', count: 990 });
|
||||
reputationMonitor.recordSendEvent('high.com', { type: 'open', count: 500 });
|
||||
|
||||
reputationMonitor.recordSendEvent('medium.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('medium.com', { type: 'delivered', count: 950 });
|
||||
reputationMonitor.recordSendEvent('medium.com', { type: 'open', count: 300 });
|
||||
|
||||
reputationMonitor.recordSendEvent('low.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('low.com', { type: 'delivered', count: 850 });
|
||||
reputationMonitor.recordSendEvent('low.com', { type: 'open', count: 100 });
|
||||
|
||||
// Get reputation summary
|
||||
const summary = reputationMonitor.getReputationSummary();
|
||||
expect(Array.isArray(summary)).toEqual(true);
|
||||
expect(summary.length >= 3).toEqual(true);
|
||||
|
||||
// Check that domains are included in the summary
|
||||
const domains = summary.map(item => item.domain);
|
||||
expect(domains.includes('high.com')).toEqual(true);
|
||||
expect(domains.includes('medium.com')).toEqual(true);
|
||||
expect(domains.includes('low.com')).toEqual(true);
|
||||
});
|
||||
|
||||
// Test adding and removing domains
|
||||
tap.test('should add and remove domains for monitoring', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: false, // Disable automatic updates to prevent race conditions
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Add a new domain
|
||||
reputationMonitor.addDomain('newdomain.com');
|
||||
|
||||
// Record data for the new domain
|
||||
reputationMonitor.recordSendEvent('newdomain.com', { type: 'sent', count: 50 });
|
||||
|
||||
// Check that data was recorded for the new domain
|
||||
const metrics = reputationMonitor.getReputationData('newdomain.com');
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(metrics.volume.sent).toEqual(50);
|
||||
|
||||
// Remove a domain
|
||||
reputationMonitor.removeDomain('newdomain.com');
|
||||
|
||||
// Check that data is no longer available
|
||||
const removedMetrics = reputationMonitor.getReputationData('newdomain.com');
|
||||
expect(removedMetrics === null).toEqual(true);
|
||||
});
|
||||
|
||||
// Test handling open and click events
|
||||
tap.test('should track engagement metrics correctly', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: false, // Disable automatic updates to prevent race conditions
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record basic sending metrics
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 950 });
|
||||
|
||||
// Record engagement events
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'open', count: 500 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'click', count: 250 });
|
||||
|
||||
// Check engagement metrics
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(metrics.engagement.opens).toEqual(500);
|
||||
expect(metrics.engagement.clicks).toEqual(250);
|
||||
expect(typeof metrics.engagement.openRate).toEqual('number');
|
||||
expect(typeof metrics.engagement.clickRate).toEqual('number');
|
||||
});
|
||||
|
||||
// Test historical data tracking
|
||||
tap.test('should store historical reputation data', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: false, // Disable automatic updates to prevent race conditions
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record events over multiple days
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
|
||||
// Record data
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 950 });
|
||||
|
||||
// Get metrics data
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
|
||||
// Check that historical data exists
|
||||
expect(metrics.historical).toBeTruthy();
|
||||
expect(metrics.historical.reputationScores).toBeTruthy();
|
||||
|
||||
// Check that daily send volume is tracked
|
||||
expect(metrics.volume.dailySendVolume).toBeTruthy();
|
||||
expect(metrics.volume.dailySendVolume[todayStr]).toEqual(1000);
|
||||
});
|
||||
|
||||
// Test event recording for different event types
|
||||
tap.test('should correctly handle different event types', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: false, // Disable automatic updates to prevent race conditions
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record different types of events
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: true, count: 3 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: false, count: 2 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'complaint', receivingDomain: 'gmail.com', count: 1 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'open', count: 50 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'click', count: 25 });
|
||||
|
||||
// Check metrics for different event types
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
|
||||
// Check volume metrics
|
||||
expect(metrics.volume.sent).toEqual(100);
|
||||
expect(metrics.volume.delivered).toEqual(95);
|
||||
expect(metrics.volume.hardBounces).toEqual(3);
|
||||
expect(metrics.volume.softBounces).toEqual(2);
|
||||
|
||||
// Check complaint metrics
|
||||
expect(metrics.complaints.total).toEqual(1);
|
||||
expect(metrics.complaints.topDomains[0].domain).toEqual('gmail.com');
|
||||
|
||||
// Check engagement metrics
|
||||
expect(metrics.engagement.opens).toEqual(50);
|
||||
expect(metrics.engagement.clicks).toEqual(25);
|
||||
});
|
||||
|
||||
// After all tests, clean up
|
||||
tap.test('cleanup', async () => {
|
||||
resetSingleton();
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,240 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.ts';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
|
||||
let dcRouter: DcRouter;
|
||||
|
||||
tap.test('should run both DNS and email with socket-handlers simultaneously', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.integration.test',
|
||||
emailConfig: {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.integration.test',
|
||||
domains: ['integration.test'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
},
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Verify both services are running
|
||||
const dnsServer = (dcRouter as any).dnsServer;
|
||||
const emailServer = (dcRouter as any).emailServer;
|
||||
|
||||
expect(dnsServer).toBeDefined();
|
||||
expect(emailServer).toBeDefined();
|
||||
|
||||
// Verify SmartProxy has routes for both services
|
||||
const smartProxy = (dcRouter as any).smartProxy;
|
||||
const routes = smartProxy?.options?.routes || [];
|
||||
|
||||
// Count DNS routes
|
||||
const dnsRoutes = routes.filter((route: any) =>
|
||||
route.name?.includes('dns-over-https')
|
||||
);
|
||||
expect(dnsRoutes.length).toEqual(2);
|
||||
|
||||
// Count email routes
|
||||
const emailRoutes = routes.filter((route: any) =>
|
||||
route.name?.includes('-route') && !route.name?.includes('dns')
|
||||
);
|
||||
expect(emailRoutes.length).toEqual(3);
|
||||
|
||||
// All routes should be socket-handler type
|
||||
[...dnsRoutes, ...emailRoutes].forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('socket-handler');
|
||||
expect(route.action.socketHandler).toBeDefined();
|
||||
});
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('should handle mixed configuration (DNS socket-handler, email traditional)', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.mixed.test',
|
||||
emailConfig: {
|
||||
ports: [25, 587],
|
||||
hostname: 'mail.mixed.test',
|
||||
domains: ['mixed.test'],
|
||||
routes: [],
|
||||
useSocketHandler: false // Traditional mode
|
||||
},
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
const smartProxy = (dcRouter as any).smartProxy;
|
||||
const routes = smartProxy?.options?.routes || [];
|
||||
|
||||
// DNS routes should be socket-handler
|
||||
const dnsRoutes = routes.filter((route: any) =>
|
||||
route.name?.includes('dns-over-https')
|
||||
);
|
||||
dnsRoutes.forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('socket-handler');
|
||||
});
|
||||
|
||||
// Email routes should be forward
|
||||
const emailRoutes = routes.filter((route: any) =>
|
||||
route.name?.includes('-route') && !route.name?.includes('dns')
|
||||
);
|
||||
emailRoutes.forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.target.port).toBeGreaterThan(10000); // Internal port
|
||||
});
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('should properly clean up resources on stop', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.cleanup.test',
|
||||
emailConfig: {
|
||||
ports: [25],
|
||||
hostname: 'mail.cleanup.test',
|
||||
domains: ['cleanup.test'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Services should be running
|
||||
expect((dcRouter as any).dnsServer).toBeDefined();
|
||||
expect((dcRouter as any).emailServer).toBeDefined();
|
||||
expect((dcRouter as any).smartProxy).toBeDefined();
|
||||
|
||||
await dcRouter.stop();
|
||||
|
||||
// After stop, services should still be defined but stopped
|
||||
// (The stop method doesn't null out the properties, just stops the services)
|
||||
expect((dcRouter as any).dnsServer).toBeDefined();
|
||||
expect((dcRouter as any).emailServer).toBeDefined();
|
||||
});
|
||||
|
||||
tap.test('should handle configuration updates correctly', async () => {
|
||||
// Start with minimal config
|
||||
dcRouter = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Initially no DNS or email
|
||||
expect((dcRouter as any).dnsServer).toBeUndefined();
|
||||
expect((dcRouter as any).emailServer).toBeUndefined();
|
||||
|
||||
// Update to add email config
|
||||
await dcRouter.updateEmailConfig({
|
||||
ports: [25],
|
||||
hostname: 'mail.update.test',
|
||||
domains: ['update.test'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
});
|
||||
|
||||
// Now email should be running
|
||||
expect((dcRouter as any).emailServer).toBeDefined();
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('performance: socket-handler should not create internal listeners', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.perf.test',
|
||||
emailConfig: {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.perf.test',
|
||||
domains: ['perf.test'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Get the number of listeners before creating handlers
|
||||
const eventCounts: { [key: string]: number } = {};
|
||||
|
||||
// DNS server should not have HTTPS listeners
|
||||
const dnsServer = (dcRouter as any).dnsServer;
|
||||
// The DNS server should exist but not bind to HTTPS port
|
||||
expect(dnsServer).toBeDefined();
|
||||
|
||||
// Email server should not have any server listeners
|
||||
const emailServer = (dcRouter as any).emailServer;
|
||||
expect(emailServer.servers.length).toEqual(0);
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('should handle errors gracefully', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.error.test',
|
||||
emailConfig: {
|
||||
ports: [25],
|
||||
hostname: 'mail.error.test',
|
||||
domains: ['error.test'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Test DNS error handling
|
||||
const dnsHandler = (dcRouter as any).createDnsSocketHandler();
|
||||
const errorSocket = new plugins.net.Socket();
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
// This should handle the error gracefully
|
||||
await dnsHandler(errorSocket);
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
}
|
||||
|
||||
// Should not throw, should handle gracefully
|
||||
expect(errorThrown).toBeFalsy();
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('should correctly identify secure connections', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
emailConfig: {
|
||||
ports: [465],
|
||||
hostname: 'mail.secure.test',
|
||||
domains: ['secure.test'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
}
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// The email socket handler for port 465 should handle TLS
|
||||
const handler = (dcRouter as any).createMailSocketHandler(465);
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
// Port 465 requires immediate TLS, which is handled in the socket handler
|
||||
// This is different from ports 25/587 which use STARTTLS
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,198 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.ts';
|
||||
|
||||
/**
|
||||
* Unit tests for socket-handler functionality
|
||||
* These tests focus on the configuration and route generation logic
|
||||
* without actually starting services on real ports
|
||||
*/
|
||||
|
||||
let dcRouter: DcRouter;
|
||||
|
||||
tap.test('DNS route generation with dnsDomain', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.unit.test'
|
||||
});
|
||||
|
||||
// Test the route generation directly
|
||||
const dnsRoutes = (dcRouter as any).generateDnsRoutes();
|
||||
|
||||
expect(dnsRoutes).toBeDefined();
|
||||
expect(dnsRoutes.length).toEqual(2);
|
||||
|
||||
// Check /dns-query route
|
||||
const dnsQueryRoute = dnsRoutes[0];
|
||||
expect(dnsQueryRoute.name).toEqual('dns-over-https-dns-query');
|
||||
expect(dnsQueryRoute.match.ports).toEqual([443]);
|
||||
expect(dnsQueryRoute.match.domains).toEqual(['dns.unit.test']);
|
||||
expect(dnsQueryRoute.match.path).toEqual('/dns-query');
|
||||
expect(dnsQueryRoute.action.type).toEqual('socket-handler');
|
||||
expect(dnsQueryRoute.action.socketHandler).toBeDefined();
|
||||
|
||||
// Check /resolve route
|
||||
const resolveRoute = dnsRoutes[1];
|
||||
expect(resolveRoute.name).toEqual('dns-over-https-resolve');
|
||||
expect(resolveRoute.match.ports).toEqual([443]);
|
||||
expect(resolveRoute.match.domains).toEqual(['dns.unit.test']);
|
||||
expect(resolveRoute.match.path).toEqual('/resolve');
|
||||
expect(resolveRoute.action.type).toEqual('socket-handler');
|
||||
expect(resolveRoute.action.socketHandler).toBeDefined();
|
||||
});
|
||||
|
||||
tap.test('DNS route generation without dnsDomain', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
// No dnsDomain set
|
||||
});
|
||||
|
||||
const dnsRoutes = (dcRouter as any).generateDnsRoutes();
|
||||
|
||||
expect(dnsRoutes).toBeDefined();
|
||||
expect(dnsRoutes.length).toEqual(0); // No routes generated
|
||||
});
|
||||
|
||||
tap.test('Email route generation with socket-handler', async () => {
|
||||
const emailConfig = {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.unit.test',
|
||||
domains: ['unit.test'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
};
|
||||
|
||||
dcRouter = new DcRouter({ emailConfig });
|
||||
|
||||
const emailRoutes = (dcRouter as any).generateEmailRoutes(emailConfig);
|
||||
|
||||
expect(emailRoutes).toBeDefined();
|
||||
expect(emailRoutes.length).toEqual(3);
|
||||
|
||||
// Check all routes use socket-handler
|
||||
emailRoutes.forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('socket-handler');
|
||||
expect(route.action.socketHandler).toBeDefined();
|
||||
expect(typeof route.action.socketHandler).toEqual('function');
|
||||
});
|
||||
|
||||
// Check specific ports
|
||||
const port25Route = emailRoutes.find((r: any) => r.match.ports[0] === 25);
|
||||
expect(port25Route.name).toEqual('smtp-route');
|
||||
|
||||
const port587Route = emailRoutes.find((r: any) => r.match.ports[0] === 587);
|
||||
expect(port587Route.name).toEqual('submission-route');
|
||||
|
||||
const port465Route = emailRoutes.find((r: any) => r.match.ports[0] === 465);
|
||||
expect(port465Route.name).toEqual('smtps-route');
|
||||
});
|
||||
|
||||
tap.test('Email route generation with traditional forwarding', async () => {
|
||||
const emailConfig = {
|
||||
ports: [25, 587],
|
||||
hostname: 'mail.unit.test',
|
||||
domains: ['unit.test'],
|
||||
routes: [],
|
||||
useSocketHandler: false // Traditional mode
|
||||
};
|
||||
|
||||
dcRouter = new DcRouter({ emailConfig });
|
||||
|
||||
const emailRoutes = (dcRouter as any).generateEmailRoutes(emailConfig);
|
||||
|
||||
expect(emailRoutes).toBeDefined();
|
||||
expect(emailRoutes.length).toEqual(2);
|
||||
|
||||
// Check all routes use forward action
|
||||
emailRoutes.forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.target).toBeDefined();
|
||||
expect(route.action.target.host).toEqual('localhost');
|
||||
expect(route.action.target.port).toBeGreaterThan(10000); // Internal port
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('Email TLS modes are set correctly', async () => {
|
||||
const emailConfig = {
|
||||
ports: [25, 465],
|
||||
hostname: 'mail.unit.test',
|
||||
domains: ['unit.test'],
|
||||
routes: [],
|
||||
useSocketHandler: false
|
||||
};
|
||||
|
||||
dcRouter = new DcRouter({ emailConfig });
|
||||
|
||||
const emailRoutes = (dcRouter as any).generateEmailRoutes(emailConfig);
|
||||
|
||||
// Port 25 should use passthrough (STARTTLS)
|
||||
const port25Route = emailRoutes.find((r: any) => r.match.ports[0] === 25);
|
||||
expect(port25Route.action.tls.mode).toEqual('passthrough');
|
||||
|
||||
// Port 465 should use terminate (implicit TLS)
|
||||
const port465Route = emailRoutes.find((r: any) => r.match.ports[0] === 465);
|
||||
expect(port465Route.action.tls.mode).toEqual('terminate');
|
||||
expect(port465Route.action.tls.certificate).toEqual('auto');
|
||||
});
|
||||
|
||||
tap.test('Combined DNS and email configuration', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.combined.test',
|
||||
emailConfig: {
|
||||
ports: [25],
|
||||
hostname: 'mail.combined.test',
|
||||
domains: ['combined.test'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
}
|
||||
});
|
||||
|
||||
// Generate both types of routes
|
||||
const dnsRoutes = (dcRouter as any).generateDnsRoutes();
|
||||
const emailRoutes = (dcRouter as any).generateEmailRoutes(dcRouter.options.emailConfig);
|
||||
|
||||
// Check DNS routes
|
||||
expect(dnsRoutes.length).toEqual(2);
|
||||
dnsRoutes.forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('socket-handler');
|
||||
expect(route.match.domains).toEqual(['dns.combined.test']);
|
||||
});
|
||||
|
||||
// Check email routes
|
||||
expect(emailRoutes.length).toEqual(1);
|
||||
expect(emailRoutes[0].action.type).toEqual('socket-handler');
|
||||
expect(emailRoutes[0].match.ports).toEqual([25]);
|
||||
});
|
||||
|
||||
tap.test('Socket handler functions are created correctly', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsDomain: 'dns.handler.test',
|
||||
emailConfig: {
|
||||
ports: [25, 465],
|
||||
hostname: 'mail.handler.test',
|
||||
domains: ['handler.test'],
|
||||
routes: [],
|
||||
useSocketHandler: true
|
||||
}
|
||||
});
|
||||
|
||||
// Test DNS socket handler creation
|
||||
const dnsHandler = (dcRouter as any).createDnsSocketHandler();
|
||||
expect(dnsHandler).toBeDefined();
|
||||
expect(typeof dnsHandler).toEqual('function');
|
||||
|
||||
// Test email socket handler creation for different ports
|
||||
const smtp25Handler = (dcRouter as any).createMailSocketHandler(25);
|
||||
expect(smtp25Handler).toBeDefined();
|
||||
expect(typeof smtp25Handler).toEqual('function');
|
||||
|
||||
const smtp465Handler = (dcRouter as any).createMailSocketHandler(465);
|
||||
expect(smtp465Handler).toBeDefined();
|
||||
expect(typeof smtp465Handler).toEqual('function');
|
||||
|
||||
// Handlers should be different functions
|
||||
expect(smtp25Handler).not.toEqual(smtp465Handler);
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,289 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import * as paths from '../ts/paths.ts';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.ts';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test data
|
||||
const testData = {
|
||||
string: 'Hello, World!',
|
||||
json: { name: 'test', value: 42, nested: { data: true } },
|
||||
largeString: 'x'.repeat(10000)
|
||||
};
|
||||
|
||||
tap.test('Storage Manager - Memory Backend', async () => {
|
||||
// Create StorageManager without config (defaults to memory)
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Test basic get/set
|
||||
await storage.set('/test/key', testData.string);
|
||||
const value = await storage.get('/test/key');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Test JSON helpers
|
||||
await storage.setJSON('/test/json', testData.json);
|
||||
const jsonValue = await storage.getJSON('/test/json');
|
||||
expect(jsonValue).toEqual(testData.json);
|
||||
|
||||
// Test exists
|
||||
expect(await storage.exists('/test/key')).toEqual(true);
|
||||
expect(await storage.exists('/nonexistent')).toEqual(false);
|
||||
|
||||
// Test delete
|
||||
await storage.delete('/test/key');
|
||||
expect(await storage.exists('/test/key')).toEqual(false);
|
||||
|
||||
// Test list
|
||||
await storage.set('/items/1', 'one');
|
||||
await storage.set('/items/2', 'two');
|
||||
await storage.set('/other/3', 'three');
|
||||
|
||||
const items = await storage.list('/items');
|
||||
expect(items.length).toEqual(2);
|
||||
expect(items).toContain('/items/1');
|
||||
expect(items).toContain('/items/2');
|
||||
|
||||
// Verify memory backend
|
||||
expect(storage.getBackend()).toEqual('memory');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Filesystem Backend', async () => {
|
||||
const testDir = path.join(paths.dataDir, '.test-storage');
|
||||
|
||||
// Clean up test directory if it exists
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
|
||||
// Create StorageManager with filesystem path
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
|
||||
// Test basic operations
|
||||
await storage.set('/test/file', testData.string);
|
||||
const value = await storage.get('/test/file');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Verify file exists on disk
|
||||
const filePath = path.join(testDir, 'test', 'file');
|
||||
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
|
||||
expect(fileExists).toEqual(true);
|
||||
|
||||
// Test atomic writes (temp file should not exist)
|
||||
const tempPath = filePath + '.tmp';
|
||||
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
|
||||
expect(tempExists).toEqual(false);
|
||||
|
||||
// Test nested paths
|
||||
await storage.set('/deeply/nested/path/to/file', testData.largeString);
|
||||
const nestedValue = await storage.get('/deeply/nested/path/to/file');
|
||||
expect(nestedValue).toEqual(testData.largeString);
|
||||
|
||||
// Test list with filesystem
|
||||
await storage.set('/fs/items/a', 'alpha');
|
||||
await storage.set('/fs/items/b', 'beta');
|
||||
await storage.set('/fs/other/c', 'gamma');
|
||||
|
||||
// Filesystem backend now properly supports list
|
||||
const fsItems = await storage.list('/fs/items');
|
||||
expect(fsItems.length).toEqual(2); // Should find both items
|
||||
|
||||
// Clean up
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Custom Function Backend', async () => {
|
||||
// Create in-memory storage for custom functions
|
||||
const customStore = new Map<string, string>();
|
||||
|
||||
const storage = new StorageManager({
|
||||
readFunction: async (key: string) => {
|
||||
return customStore.get(key) || null;
|
||||
},
|
||||
writeFunction: async (key: string, value: string) => {
|
||||
customStore.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Test basic operations
|
||||
await storage.set('/custom/key', testData.string);
|
||||
expect(customStore.has('/custom/key')).toEqual(true);
|
||||
|
||||
const value = await storage.get('/custom/key');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Test that delete sets empty value (as per implementation)
|
||||
await storage.delete('/custom/key');
|
||||
expect(customStore.get('/custom/key')).toEqual('');
|
||||
|
||||
// Verify custom backend (filesystem is implemented as custom backend internally)
|
||||
expect(storage.getBackend()).toEqual('custom');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Key Validation', async () => {
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Test key normalization
|
||||
await storage.set('test/key', 'value1'); // Missing leading slash
|
||||
const value1 = await storage.get('/test/key');
|
||||
expect(value1).toEqual('value1');
|
||||
|
||||
// Test dangerous path elements are removed
|
||||
await storage.set('/test/../danger/key', 'value2');
|
||||
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
|
||||
expect(value2).toEqual('value2');
|
||||
|
||||
// Test multiple slashes are normalized
|
||||
await storage.set('/test///multiple////slashes', 'value3');
|
||||
const value3 = await storage.get('/test/multiple/slashes');
|
||||
expect(value3).toEqual('value3');
|
||||
|
||||
// Test invalid keys throw errors
|
||||
let emptyKeyError: Error | null = null;
|
||||
try {
|
||||
await storage.set('', 'value');
|
||||
} catch (error) {
|
||||
emptyKeyError = error as Error;
|
||||
}
|
||||
expect(emptyKeyError).toBeTruthy();
|
||||
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||
|
||||
let nullKeyError: Error | null = null;
|
||||
try {
|
||||
await storage.set(null as any, 'value');
|
||||
} catch (error) {
|
||||
nullKeyError = error as Error;
|
||||
}
|
||||
expect(nullKeyError).toBeTruthy();
|
||||
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Concurrent Access', async () => {
|
||||
const storage = new StorageManager();
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
// Simulate concurrent writes
|
||||
for (let i = 0; i < 100; i++) {
|
||||
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Verify all writes succeeded
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const value = await storage.get(`/concurrent/key${i}`);
|
||||
expect(value).toEqual(`value${i}`);
|
||||
}
|
||||
|
||||
// Test concurrent reads
|
||||
const readPromises: Promise<string | null>[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
readPromises.push(storage.get(`/concurrent/key${i}`));
|
||||
}
|
||||
|
||||
const results = await Promise.all(readPromises);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(results[i]).toEqual(`value${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Backend Priority', async () => {
|
||||
const testDir = path.join(paths.dataDir, '.test-storage-priority');
|
||||
|
||||
// Test that custom functions take priority over fsPath
|
||||
let warningLogged = false;
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (message: string) => {
|
||||
if (message.includes('Using custom read/write functions')) {
|
||||
warningLogged = true;
|
||||
}
|
||||
};
|
||||
|
||||
const storage = new StorageManager({
|
||||
fsPath: testDir,
|
||||
readFunction: async () => 'custom-value',
|
||||
writeFunction: async () => {}
|
||||
});
|
||||
|
||||
console.warn = originalWarn;
|
||||
|
||||
expect(warningLogged).toEqual(true);
|
||||
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
|
||||
|
||||
// Clean up
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Error Handling', async () => {
|
||||
// Test filesystem errors
|
||||
const storage = new StorageManager({
|
||||
readFunction: async () => {
|
||||
throw new Error('Read error');
|
||||
},
|
||||
writeFunction: async () => {
|
||||
throw new Error('Write error');
|
||||
}
|
||||
});
|
||||
|
||||
// Read errors should return null
|
||||
const value = await storage.get('/error/key');
|
||||
expect(value).toEqual(null);
|
||||
|
||||
// Write errors should propagate
|
||||
let writeError: Error | null = null;
|
||||
try {
|
||||
await storage.set('/error/key', 'value');
|
||||
} catch (error) {
|
||||
writeError = error as Error;
|
||||
}
|
||||
expect(writeError).toBeTruthy();
|
||||
expect(writeError?.message).toEqual('Write error');
|
||||
|
||||
// Test JSON parse errors
|
||||
const jsonStorage = new StorageManager({
|
||||
readFunction: async () => 'invalid json',
|
||||
writeFunction: async () => {}
|
||||
});
|
||||
|
||||
// Test JSON parse errors
|
||||
let jsonError: Error | null = null;
|
||||
try {
|
||||
await jsonStorage.getJSON('/invalid/json');
|
||||
} catch (error) {
|
||||
jsonError = error as Error;
|
||||
}
|
||||
expect(jsonError).toBeTruthy();
|
||||
expect(jsonError?.message).toContain('JSON');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - List Operations', async () => {
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Populate storage with hierarchical data
|
||||
await storage.set('/app/config/database', 'db-config');
|
||||
await storage.set('/app/config/cache', 'cache-config');
|
||||
await storage.set('/app/data/users/1', 'user1');
|
||||
await storage.set('/app/data/users/2', 'user2');
|
||||
await storage.set('/app/logs/error.log', 'errors');
|
||||
|
||||
// List root
|
||||
const rootItems = await storage.list('/');
|
||||
expect(rootItems.length).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// List specific paths
|
||||
const configItems = await storage.list('/app/config');
|
||||
expect(configItems.length).toEqual(2);
|
||||
expect(configItems).toContain('/app/config/database');
|
||||
expect(configItems).toContain('/app/config/cache');
|
||||
|
||||
const userItems = await storage.list('/app/data/users');
|
||||
expect(userItems.length).toEqual(2);
|
||||
|
||||
// List non-existent path
|
||||
const emptyList = await storage.list('/nonexistent/path');
|
||||
expect(emptyList.length).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user