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