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