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:
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();
|
Reference in New Issue
Block a user