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:
2025-05-30 07:00:59 +00:00
parent 40db395591
commit 53b64025f3
8 changed files with 1180 additions and 607 deletions

View 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
View 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();

View 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
View 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();