- 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
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
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(); |