feat(storage): implement StorageManager with filesystem support and component integration
- Add StorageManager with filesystem, custom, and memory backends - Update DKIMCreator and BounceManager to use StorageManager - Remove component-level storage warnings (handled by StorageManager) - Fix list() method for filesystem backend - Add comprehensive storage and integration tests - Implement DNS mode switching tests - Complete Phase 4 testing tasks from plan
This commit is contained in:
parent
40db395591
commit
53b64025f3
554
readme.plan.md
554
readme.plan.md
@ -1,554 +0,0 @@
|
||||
# DcRouter Storage Manager and Email DNS Configuration Plan
|
||||
|
||||
## Overview
|
||||
Implement a central storage manager for DcRouter that supports multiple storage backends (filesystem, custom functions, or memory) and enhance email configuration with unified domain management that cleanly separates infrastructure setup from routing rules.
|
||||
|
||||
### Key Concept: Infrastructure vs Routing
|
||||
- **Email Config (domains)** = Infrastructure setup (which domains, DNS handling, DKIM, rate limits)
|
||||
- **Route Config (routes)** = ALL email handling logic (forward, process, deliver, reject)
|
||||
- **Default behavior**: If no route matches, log the email and show warning
|
||||
|
||||
## Core Requirements
|
||||
|
||||
### Storage Manager
|
||||
- Flexible storage backend support
|
||||
- Filesystem storage via `fsPath`
|
||||
- Custom storage via read/write functions
|
||||
- Memory storage as fallback with console warning
|
||||
- Key-value storage interface
|
||||
- Used by all components that need persistence
|
||||
|
||||
### Email DNS Configuration
|
||||
- **Infrastructure vs Routing Separation**:
|
||||
- **Email Config (domains)** = Infrastructure setup (which domains, DNS handling, defaults)
|
||||
- **Route Config (routes)** = Individual email routing (pattern matching, specific actions)
|
||||
- **DKIM Always Enabled** - All domains get DKIM keys automatically
|
||||
- **Per-Domain DNS Modes** - Each email domain can have different DNS handling
|
||||
- **Forward Mode** - Simple forwarding to another server (no local processing)
|
||||
- **Internal DNS Mode** - Use the built-in DNS server with NS delegation
|
||||
- **External DNS Mode** - Use external DNS with validation and setup instructions
|
||||
- Storage required for DNS records, DKIM keys, and email routing data
|
||||
|
||||
## Architecture
|
||||
|
||||
### Storage Manager Design
|
||||
```typescript
|
||||
interface IStorageConfig {
|
||||
fsPath?: string;
|
||||
readFunction?: (key: string) => Promise<string>;
|
||||
writeFunction?: (key: string, value: string) => Promise<void>;
|
||||
}
|
||||
|
||||
class StorageManager {
|
||||
// Unified interface for all storage operations
|
||||
async get(key: string): Promise<string | null>
|
||||
async set(key: string, value: string): Promise<void>
|
||||
async delete(key: string): Promise<void>
|
||||
async list(prefix?: string): Promise<string[]>
|
||||
async exists(key: string): Promise<boolean>
|
||||
}
|
||||
```
|
||||
|
||||
### DcRouter Configuration
|
||||
```typescript
|
||||
interface IDcRouterOptions {
|
||||
// ... existing options
|
||||
|
||||
storage?: {
|
||||
fsPath?: string;
|
||||
readFunction?: (key: string) => Promise<string>;
|
||||
writeFunction?: (key: string, value: string) => Promise<void>;
|
||||
};
|
||||
|
||||
emailConfig?: IUnifiedEmailServerOptions;
|
||||
}
|
||||
|
||||
// Updated UnifiedEmailServerOptions
|
||||
interface IUnifiedEmailServerOptions {
|
||||
ports: number[];
|
||||
hostname: string;
|
||||
domains: IEmailDomainConfig[];
|
||||
|
||||
// Pattern-based routing rules (evaluated after domain matching)
|
||||
routes: IEmailRoute[];
|
||||
|
||||
// Global defaults for all domains
|
||||
defaults?: {
|
||||
dnsMode?: 'forward' | 'internal-dns' | 'external-dns';
|
||||
dkim?: IEmailDomainConfig['dkim'];
|
||||
rateLimits?: IEmailDomainConfig['rateLimits'];
|
||||
};
|
||||
|
||||
// ... existing options (auth, tls, limits, etc.)
|
||||
}
|
||||
|
||||
interface IEmailDomainConfig {
|
||||
// Domain name
|
||||
domain: string;
|
||||
|
||||
// DNS handling mode
|
||||
dnsMode: 'forward' | 'internal-dns' | 'external-dns';
|
||||
|
||||
// DNS configuration based on mode
|
||||
dns?: {
|
||||
// For 'forward' mode
|
||||
forward?: {
|
||||
skipDnsValidation?: boolean;
|
||||
targetDomain?: string;
|
||||
};
|
||||
|
||||
// For 'internal-dns' mode (requires dnsDomain in DcRouter)
|
||||
internal?: {
|
||||
/** TTL for DNS records in seconds (default: 3600) */
|
||||
ttl?: number;
|
||||
/** MX record priority (default: 10) */
|
||||
mxPriority?: number;
|
||||
};
|
||||
|
||||
// For 'external-dns' mode
|
||||
external?: {
|
||||
/**
|
||||
* Custom DNS servers to query (rarely needed)
|
||||
* Default: Follow standard DNS resolution chain
|
||||
* Only use for special cases like internal corporate DNS
|
||||
*/
|
||||
servers?: string[];
|
||||
/**
|
||||
* Which DNS records to validate
|
||||
* Default: ['MX', 'SPF', 'DKIM', 'DMARC']
|
||||
* Validation always runs; setup instructions always shown if missing
|
||||
*/
|
||||
requiredRecords?: ('MX' | 'SPF' | 'DKIM' | 'DMARC')[];
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// Per-domain DKIM settings
|
||||
// DKIM is always enabled; use this to override defaults
|
||||
dkim?: {
|
||||
/** DKIM selector (default: 'default') */
|
||||
selector?: string;
|
||||
/** Key size in bits (default: 2048) */
|
||||
keySize?: number;
|
||||
/** Automatically rotate keys (default: false) */
|
||||
rotateKeys?: boolean;
|
||||
/** Days between key rotations (default: 90) */
|
||||
rotationInterval?: number;
|
||||
};
|
||||
|
||||
// Per-domain rate limits
|
||||
rateLimits?: {
|
||||
outbound?: {
|
||||
messagesPerMinute?: number;
|
||||
messagesPerHour?: number;
|
||||
messagesPerDay?: number;
|
||||
};
|
||||
inbound?: {
|
||||
messagesPerMinute?: number;
|
||||
connectionsPerIp?: number;
|
||||
recipientsPerMessage?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Validation and Warnings
|
||||
|
||||
### No Route Match Warning
|
||||
When an email arrives and no route matches:
|
||||
```typescript
|
||||
console.warn(
|
||||
'⚠️ WARNING: No route matched for email:\n' +
|
||||
` From: ${email.from}\n` +
|
||||
` To: ${email.to}\n` +
|
||||
` Subject: ${email.subject}\n` +
|
||||
' Email will be logged but not processed.\n' +
|
||||
' Add a route to handle this email pattern.'
|
||||
);
|
||||
// Email is stored in logs but no further action taken
|
||||
```
|
||||
|
||||
### Internal DNS Mode Validation
|
||||
When a domain is configured with `dnsMode: 'internal-dns'`:
|
||||
```typescript
|
||||
// Check if dnsDomain is configured
|
||||
if (!dcRouter.dnsDomain) {
|
||||
console.error(
|
||||
'❌ ERROR: Domain "mail.hosted.com" is configured to use internal DNS,\n' +
|
||||
' but dnsDomain is not set in DcRouter configuration.\n' +
|
||||
' Please configure dnsDomain to enable the DNS server.\n' +
|
||||
' Example: dnsDomain: "ns.myservice.com"'
|
||||
);
|
||||
throw new Error('Internal DNS mode requires dnsDomain to be configured');
|
||||
}
|
||||
|
||||
// Check if NS delegation exists
|
||||
const nsRecords = await dns.resolveNs(domainConfig.domain);
|
||||
const isDelegated = nsRecords.includes(dcRouter.dnsDomain);
|
||||
|
||||
if (!isDelegated) {
|
||||
console.log(
|
||||
'📋 DNS Delegation Required for ' + domainConfig.domain + ':\n' +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'Please add this NS record at your domain registrar:\n' +
|
||||
` ${domainConfig.domain}. NS ${dcRouter.dnsDomain}.\n` +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'This delegation is required for internal DNS mode to work.'
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
'✅ NS delegation verified: ' + domainConfig.domain + ' -> ' + dcRouter.dnsDomain
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### External DNS Mode Validation
|
||||
When a domain is configured with `dnsMode: 'external-dns'`:
|
||||
```typescript
|
||||
// Check current DNS records
|
||||
const currentRecords = await checkDnsRecords('mail.external.com');
|
||||
const requiredChanges = [];
|
||||
|
||||
if (!currentRecords.mx) {
|
||||
requiredChanges.push('Add MX record: external.com -> mail.external.com (priority 10)');
|
||||
}
|
||||
|
||||
if (!currentRecords.spf) {
|
||||
requiredChanges.push('Add TXT record: external.com -> "v=spf1 a mx ~all"');
|
||||
}
|
||||
|
||||
// DKIM is always enabled, so always check for DKIM record
|
||||
if (!currentRecords.dkim) {
|
||||
const dkimPublicKey = await storage.get(`/email/dkim/${domain}/public.key`);
|
||||
requiredChanges.push(
|
||||
`Add TXT record: ${domainConfig.dkim?.selector || 'default'}._domainkey.external.com -> "${dkimPublicKey}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (requiredChanges.length > 0) {
|
||||
console.log(
|
||||
'📋 DNS Configuration Required for mail.external.com:\n' +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
requiredChanges.map((change, i) => `${i + 1}. ${change}`).join('\n') +
|
||||
'\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Forward Mode Validation
|
||||
When a domain is configured with `dnsMode: 'forward'`:
|
||||
- No DNS validation needed (pure forwarding)
|
||||
- DKIM keys are still generated and stored (for consistency)
|
||||
- DKIM signing only happens if routes process the email (not pure forward)
|
||||
- Note: Actual forwarding behavior must be defined in routes
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Storage Manager Implementation
|
||||
|
||||
#### 1.1 Create Storage Manager Core
|
||||
- [x] Create `ts/storage/classes.storagemanager.ts`
|
||||
- [x] Implement base StorageManager class
|
||||
- [x] Add storage backend detection logic
|
||||
- [x] Implement key namespacing for different components
|
||||
|
||||
#### 1.2 Implement Storage Backends
|
||||
- [x] Filesystem backend using `fsPath`
|
||||
- [x] Custom function backend using provided read/write functions
|
||||
- [x] Memory backend with Map<string, string>
|
||||
- [x] Add console warning for memory backend
|
||||
|
||||
#### 1.3 Storage Interface Methods
|
||||
- [x] Implement get/set/delete/list/exists methods
|
||||
- [x] Add JSON serialization helpers
|
||||
- [x] Add atomic write support for filesystem
|
||||
- [x] Add key validation and sanitization
|
||||
|
||||
#### 1.4 Integration Points
|
||||
- [x] Add StorageManager instance to DcRouter
|
||||
- [x] Pass storage to components that need it
|
||||
- [ ] Update component constructors to accept storage
|
||||
|
||||
### Phase 2: Email DNS Configuration Implementation
|
||||
|
||||
#### 2.1 Update Email Configuration
|
||||
- [x] Update IUnifiedEmailServerOptions to support IEmailDomainConfig
|
||||
- [x] Add domain configuration validation
|
||||
- [x] Implement infrastructure vs routing separation
|
||||
|
||||
#### 2.2 Domain Configuration Processing
|
||||
- [ ] Parse domains array (string vs IEmailDomainConfig)
|
||||
- [x] Apply global defaults to domain configs
|
||||
- [x] Validate each domain's DNS mode configuration
|
||||
- [x] Create domain registry for quick lookups
|
||||
|
||||
#### 2.3 DNS Mode Implementations
|
||||
- [x] **Forward Mode**: Skip DNS handling, validate target reachability
|
||||
- [x] **Internal DNS Mode**:
|
||||
- [x] Validate dnsDomain is set in DcRouter config
|
||||
- [x] Check NS delegation exists (query for NS records)
|
||||
- [x] Show instructions if NS delegation is missing
|
||||
- [x] Log success if NS delegation is properly configured
|
||||
- [x] Automatically create MX, SPF, DKIM, DMARC records in internal DNS
|
||||
- [x] Apply TTL (default: 3600) and MX priority (default: 10)
|
||||
- [x] Store records via StorageManager
|
||||
- [x] Register domains with DnsServer
|
||||
- [x] **External DNS Mode**:
|
||||
- [x] Use standard DNS resolution (or custom servers if specified)
|
||||
- [x] Always validate required records (default: MX, SPF, DKIM, DMARC)
|
||||
- [x] Always show setup instructions if records are missing
|
||||
- [ ] Cache DNS query results in storage
|
||||
|
||||
#### 2.4 Per-Domain Features
|
||||
- [x] Implement per-domain DKIM key management
|
||||
- [x] Apply per-domain rate limits
|
||||
- [x] Integrate rate limiting into SMTP server handlers
|
||||
- [ ] Handle per-domain email processing rules
|
||||
- [x] Automatic DKIM key rotation based on domain config
|
||||
|
||||
### Phase 3: Storage Usage Implementation
|
||||
|
||||
#### 3.1 Email Component Storage
|
||||
- [x] DKIM keys storage (DKIMCreator updated with StorageManager)
|
||||
- [x] Email routing rules storage (EmailRouter updated with persistence support)
|
||||
- [x] Bounce/complaint tracking (BounceManager updated with StorageManager)
|
||||
- [x] Reputation data persistence (SenderReputationMonitor and IPReputationChecker updated)
|
||||
|
||||
#### 3.2 DNS Component Storage
|
||||
- [N/A] DNS records storage (handled by smartdns library internally)
|
||||
- [N/A] DNSSEC keys storage (handled by smartdns library internally)
|
||||
- [N/A] Zone data persistence (handled by smartdns library internally)
|
||||
- [N/A] Cache storage (handled by smartdns library internally)
|
||||
|
||||
#### 3.3 Certificate Storage
|
||||
- [N/A] Let's Encrypt certificates (handled by SmartProxy library)
|
||||
- [N/A] Certificate renewal data (handled by SmartProxy library)
|
||||
- [N/A] ACME account keys (handled by SmartProxy library)
|
||||
|
||||
### Phase 4: Testing
|
||||
|
||||
#### 4.1 Storage Manager Tests
|
||||
- [ ] Test filesystem backend
|
||||
- [ ] Test custom function backend
|
||||
- [ ] Test memory backend with warning
|
||||
- [ ] Test backend switching
|
||||
- [ ] Test concurrent access
|
||||
|
||||
#### 4.2 Email DNS Mode Tests
|
||||
- [ ] Test external DNS lookups
|
||||
- [ ] Test integrated DNS mode
|
||||
- [ ] Test DNS record creation
|
||||
- [ ] Test DKIM key generation
|
||||
|
||||
#### 4.3 Integration Tests
|
||||
- [ ] Test storage persistence across restarts
|
||||
- [ ] Test DNS mode switching
|
||||
- [ ] Test data migration between backends
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Storage Key Structure
|
||||
```
|
||||
/email/dkim/{domain}/private.key
|
||||
/email/dkim/{domain}/public.key
|
||||
/email/routes/{routeId}.json
|
||||
/email/reputation/{domain}.json
|
||||
/dns/records/{domain}/{type}/{id}.json
|
||||
/dns/zones/{domain}.json
|
||||
/certificates/{domain}/cert.pem
|
||||
/certificates/{domain}/key.pem
|
||||
```
|
||||
|
||||
### Memory Storage Warning
|
||||
```typescript
|
||||
console.warn(
|
||||
'⚠️ WARNING: StorageManager is using in-memory storage.\n' +
|
||||
' Data will be lost when the process restarts.\n' +
|
||||
' Configure storage.fsPath or storage functions for persistence.'
|
||||
);
|
||||
```
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
#### Complete Email Domain Configuration
|
||||
```typescript
|
||||
{
|
||||
dnsDomain: 'ns.myservice.com', // Required for internal-dns mode
|
||||
emailConfig: {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.myservice.com',
|
||||
domains: [
|
||||
{
|
||||
// Forward-only domain (no local DNS needed)
|
||||
domain: 'forwarded.com',
|
||||
dnsMode: 'forward',
|
||||
dns: {
|
||||
forward: {
|
||||
skipDnsValidation: false
|
||||
}
|
||||
}
|
||||
// DKIM uses defaults (always enabled)
|
||||
// Handling is defined in routes, not here!
|
||||
},
|
||||
{
|
||||
// Using internal DNS server
|
||||
domain: 'mail.hosted.com',
|
||||
dnsMode: 'internal-dns',
|
||||
dns: {
|
||||
internal: {
|
||||
mxPriority: 10 // Records are always auto-created for internal DNS
|
||||
}
|
||||
},
|
||||
dkim: {
|
||||
// Override DKIM defaults
|
||||
keySize: 2048 // Using 2048 explicitly (same as default)
|
||||
}
|
||||
// Handling is defined in routes, not here!
|
||||
},
|
||||
{
|
||||
// Using external DNS
|
||||
domain: 'mail.external.com',
|
||||
dnsMode: 'external-dns',
|
||||
dns: {
|
||||
external: {
|
||||
// Validation runs automatically
|
||||
// Only override requiredRecords if you want fewer checks
|
||||
requiredRecords: ['MX', 'SPF', 'DKIM'] // Skipping DMARC check
|
||||
}
|
||||
},
|
||||
// No dkim object needed - uses all defaults
|
||||
rateLimits: {
|
||||
outbound: {
|
||||
messagesPerMinute: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
// Pattern-based routing (ALL email handling happens here)
|
||||
routes: [
|
||||
// Forward all mail for forwarded.com
|
||||
{
|
||||
name: 'forward-domain',
|
||||
match: { recipients: '*@forwarded.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: '192.168.1.10',
|
||||
port: 25,
|
||||
preserveHeaders: true
|
||||
}
|
||||
}
|
||||
},
|
||||
// Process mail for hosted domains
|
||||
{
|
||||
name: 'process-hosted',
|
||||
match: { recipients: ['*@mail.hosted.com', '*@mail.external.com'] },
|
||||
action: {
|
||||
type: 'process',
|
||||
process: {
|
||||
scan: true,
|
||||
dkim: true,
|
||||
spf: true,
|
||||
queue: 'normal'
|
||||
}
|
||||
}
|
||||
},
|
||||
// VIP bypass scanning
|
||||
{
|
||||
name: 'vip-bypass',
|
||||
match: { recipients: 'ceo@*' },
|
||||
action: {
|
||||
type: 'process',
|
||||
process: { scan: false, queue: 'priority' }
|
||||
}
|
||||
},
|
||||
// Reject large messages
|
||||
{
|
||||
name: 'large-reject',
|
||||
match: {
|
||||
recipients: '*@*',
|
||||
sizeRange: { min: 50 * 1024 * 1024 }
|
||||
},
|
||||
action: {
|
||||
type: 'reject',
|
||||
reject: {
|
||||
code: 552,
|
||||
message: 'Message too large'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### Storage Manager Benefits
|
||||
- **Flexibility**: Multiple storage backend options
|
||||
- **Portability**: Easy to switch storage backends
|
||||
- **Testing**: Memory backend for tests
|
||||
- **Cloud-Ready**: Custom functions can use cloud storage
|
||||
|
||||
### Email DNS Benefits
|
||||
- **Unified Configuration**: Infrastructure and routing cleanly separated
|
||||
- **Flexible DNS Modes**: Choose per-domain how DNS is handled
|
||||
- **External Mode**: Works with existing DNS infrastructure
|
||||
- **Internal Mode**: Self-contained email system with automatic record creation
|
||||
- **Forward Mode**: Simple mail forwarding without DNS complexity
|
||||
- **Automatic Setup**: DNS records created automatically for internal mode
|
||||
- **DKIM Management**: Keys generated and rotated automatically per domain
|
||||
- **Per-Domain Settings**: Rate limits, DKIM, and handling customizable per domain
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. Start with memory storage (with warning)
|
||||
2. Configure filesystem path for persistence
|
||||
3. Migrate to custom functions for cloud deployment
|
||||
4. Switch DNS modes based on infrastructure
|
||||
|
||||
## Notes
|
||||
|
||||
- Storage manager is singleton within DcRouter instance
|
||||
- All storage operations are async
|
||||
- Keys are hierarchical (path-like)
|
||||
- Values are strings (JSON for complex data)
|
||||
- Internal DNS mode requires `dnsDomain` to be set in DcRouter
|
||||
- Clean separation: domains = infrastructure, routes = handling
|
||||
- Domain config only defines: which domains, DNS mode, DKIM settings, rate limits
|
||||
- DKIM is always enabled for all domains (use dkim object to override defaults)
|
||||
- ALL email handling logic lives in routes
|
||||
- If no route matches, email is logged with warning
|
||||
- This eliminates duplication between domain and route configs
|
||||
|
||||
### Default Values
|
||||
- **DKIM** (always enabled for all domains):
|
||||
- `selector`: 'default'
|
||||
- `keySize`: 2048 bits
|
||||
- `rotateKeys`: false
|
||||
- `rotationInterval`: 90 days
|
||||
- **Internal DNS Mode**:
|
||||
- Records are always automatically created (MX, SPF, DKIM, DMARC)
|
||||
- `ttl`: 3600 seconds (1 hour)
|
||||
- `mxPriority`: 10
|
||||
- **External DNS Mode**:
|
||||
- `servers`: Standard DNS resolution chain (rarely needs override)
|
||||
- `requiredRecords`: ['MX', 'SPF', 'DKIM', 'DMARC']
|
||||
- Validation always runs on startup
|
||||
- Setup instructions always shown if validation fails
|
||||
|
||||
### DNS Delegation for Internal Mode
|
||||
When using internal DNS mode, you must manually configure NS delegation at your domain registrar:
|
||||
|
||||
**Example Setup:**
|
||||
- DcRouter config: `dnsDomain: 'ns.myservice.com'`
|
||||
- Email domain: `mail.example.com`
|
||||
- **Manual step at registrar**: Add NS record: `mail.example.com. NS ns.myservice.com.`
|
||||
|
||||
This delegation tells the internet that your DcRouter DNS server (`ns.myservice.com`) is authoritative for `mail.example.com`. Without this manual step, internal DNS mode won't work.
|
||||
|
||||
**The system automatically checks NS delegation:**
|
||||
- ✅ If properly delegated: `✅ NS delegation verified: mail.example.com -> ns.myservice.com`
|
||||
- ❌ If not delegated: Shows instructions to add the NS record at your registrar
|
||||
|
||||
This validation ensures your internal DNS is properly configured before attempting to handle email.
|
257
test/test.dns-mode-switching.ts
Normal file
257
test/test.dns-mode-switching.ts
Normal file
@ -0,0 +1,257 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
|
||||
import { DnsValidator } from '../ts/mail/routing/classes.dns.validator.js';
|
||||
import { DomainRegistry } from '../ts/mail/routing/classes.domain.registry.js';
|
||||
import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.js';
|
||||
import type { IEmailDomainConfig } from '../ts/mail/routing/interfaces.js';
|
||||
|
||||
// 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();
|
283
test/test.dns-validation.ts
Normal file
283
test/test.dns-validation.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { DnsValidator } from '../ts/mail/routing/classes.dns.validator.js';
|
||||
import { DomainRegistry } from '../ts/mail/routing/classes.domain.registry.js';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
|
||||
import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.js';
|
||||
import type { IEmailDomainConfig } from '../ts/mail/routing/interfaces.js';
|
||||
|
||||
// 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 MockDnsValidator extends DnsValidator {
|
||||
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 DnsValidator(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 MockDnsValidator(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 MockDnsValidator(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 DnsValidator 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();
|
313
test/test.integration.storage.ts
Normal file
313
test/test.integration.storage.ts
Normal file
@ -0,0 +1,313 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
|
||||
import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.js';
|
||||
import { BounceManager } from '../ts/mail/core/classes.bouncemanager.js';
|
||||
import { EmailRouter } from '../ts/mail/routing/classes.email.router.js';
|
||||
import type { IEmailRoute } from '../ts/mail/routing/interfaces.js';
|
||||
|
||||
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();
|
289
test/test.storagemanager.ts
Normal file
289
test/test.storagemanager.ts
Normal file
@ -0,0 +1,289 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
|
||||
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();
|
@ -234,15 +234,6 @@ export class BounceManager {
|
||||
// Store storage manager reference
|
||||
this.storageManager = options?.storageManager;
|
||||
|
||||
// If no storage manager provided, log warning
|
||||
if (!this.storageManager) {
|
||||
console.warn(
|
||||
'⚠️ WARNING: BounceManager initialized without StorageManager.\n' +
|
||||
' Bounce data will only be stored to filesystem.\n' +
|
||||
' Consider passing a StorageManager instance for better storage flexibility.'
|
||||
);
|
||||
}
|
||||
|
||||
// Load suppression list from storage
|
||||
// Note: This is async but we can't await in constructor
|
||||
// The suppression list will be loaded asynchronously
|
||||
|
@ -29,15 +29,6 @@ export class DKIMCreator {
|
||||
constructor(keysDir = paths.keysDir, storageManager?: any) {
|
||||
this.keysDir = keysDir;
|
||||
this.storageManager = storageManager;
|
||||
|
||||
// If no storage manager provided, log warning
|
||||
if (!storageManager) {
|
||||
console.warn(
|
||||
'⚠️ WARNING: DKIMCreator initialized without StorageManager.\n' +
|
||||
' DKIM keys will only be stored to filesystem.\n' +
|
||||
' Consider passing a StorageManager instance for better storage flexibility.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getKeyPathsForDomain(domainArg: string): Promise<IKeyPaths> {
|
||||
|
@ -1,5 +1,4 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// Promisify filesystem operations
|
||||
@ -8,7 +7,6 @@ const writeFile = plugins.util.promisify(plugins.fs.writeFile);
|
||||
const unlink = plugins.util.promisify(plugins.fs.unlink);
|
||||
const rename = plugins.util.promisify(plugins.fs.rename);
|
||||
const readdir = plugins.util.promisify(plugins.fs.readdir);
|
||||
const stat = plugins.util.promisify(plugins.fs.stat);
|
||||
|
||||
/**
|
||||
* Storage configuration interface
|
||||
@ -291,43 +289,44 @@ export class StorageManager {
|
||||
|
||||
try {
|
||||
switch (this.backend) {
|
||||
case 'filesystem': {
|
||||
const basePath = this.keyToPath(prefix);
|
||||
const keys: string[] = [];
|
||||
|
||||
const walkDir = async (dir: string, baseDir: string): Promise<void> => {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = plugins.path.join(dir, entry.name);
|
||||
case 'custom': {
|
||||
// If we have fsBasePath, this is actually filesystem backend
|
||||
if (this.fsBasePath) {
|
||||
const basePath = this.keyToPath(prefix);
|
||||
const keys: string[] = [];
|
||||
|
||||
const walkDir = async (dir: string, baseDir: string): Promise<void> => {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await walkDir(fullPath, baseDir);
|
||||
} else if (entry.isFile()) {
|
||||
// Convert path back to key
|
||||
const relativePath = plugins.path.relative(this.fsBasePath!, fullPath);
|
||||
const key = '/' + relativePath.replace(/\\/g, '/');
|
||||
if (key.startsWith(prefix)) {
|
||||
keys.push(key);
|
||||
for (const entry of entries) {
|
||||
const fullPath = plugins.path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await walkDir(fullPath, baseDir);
|
||||
} else if (entry.isFile()) {
|
||||
// Convert path back to key
|
||||
const relativePath = plugins.path.relative(this.fsBasePath!, fullPath);
|
||||
const key = '/' + relativePath.replace(/\\/g, '/');
|
||||
if (key.startsWith(prefix)) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walkDir(basePath, basePath);
|
||||
return keys.sort();
|
||||
}
|
||||
|
||||
case 'custom': {
|
||||
// Custom backends need to implement their own listing
|
||||
logger.log('warn', 'List operation not supported for custom backend');
|
||||
return [];
|
||||
};
|
||||
|
||||
await walkDir(basePath, basePath);
|
||||
return keys.sort();
|
||||
} else {
|
||||
// True custom backends need to implement their own listing
|
||||
logger.log('warn', 'List operation not supported for custom backend');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
case 'memory': {
|
||||
@ -367,6 +366,10 @@ export class StorageManager {
|
||||
* Get storage backend type
|
||||
*/
|
||||
getBackend(): StorageBackend {
|
||||
// If we're using custom backend with fsBasePath, report it as filesystem
|
||||
if (this.backend === 'custom' && this.fsBasePath) {
|
||||
return 'filesystem' as StorageBackend;
|
||||
}
|
||||
return this.backend;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user