const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals'); const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3'); const path = require('path'); const fs = require('fs').promises; const crypto = require('crypto'); // Load services const backupService = require('../../src/services/backupService'); const S3StorageAdapter = require('../../src/services/storage/s3Storage'); const { db, initialize: initDb } = require('../../src/database/db'); const logger = require('../../src/utils/logger'); // Test configuration const TEST_CONFIG = { endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:9000', accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin', secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin', bucket: 'test-backup-bucket-' + Date.now(), region: 'us-east-1' }; describe('S3 Backup Integration Tests', () => { let s3Client; let testStoragePath; let originalEnv; beforeAll(async () => { // Skip if no S3 endpoint configured if (process.env.SKIP_S3_TESTS === 'true') { console.log('Skipping S3 integration tests (SKIP_S3_TESTS=true)'); return; } // Save original environment originalEnv = { ...process.env }; // Initialize S3 client for test setup s3Client = new S3Client({ endpoint: TEST_CONFIG.endpoint, region: TEST_CONFIG.region, credentials: { accessKeyId: TEST_CONFIG.accessKeyId, secretAccessKey: TEST_CONFIG.secretAccessKey }, forcePathStyle: true }); // Create test bucket try { await s3Client.send(new CreateBucketCommand({ Bucket: TEST_CONFIG.bucket })); console.log(`Created test bucket: ${TEST_CONFIG.bucket}`); } catch (error) { if (error.name !== 'BucketAlreadyOwnedByYou') { console.error('Failed to create test bucket:', error); throw error; } } // Initialize database await initDb(); await db.migrate.latest(); // Create test storage directory testStoragePath = path.join(__dirname, '../fixtures/test-storage'); await fs.mkdir(testStoragePath, { recursive: true }); process.env.STORAGE_PATH = testStoragePath; // Set up test data await setupTestData(); // Mock logger to reduce noise logger.info = jest.fn(); logger.debug = jest.fn(); logger.warn = jest.fn(); logger.error = jest.fn(); }); afterAll(async () => { if (process.env.SKIP_S3_TESTS === 'true') return; try { // Clean up S3 bucket await cleanupS3Bucket(); await s3Client.send(new DeleteBucketCommand({ Bucket: TEST_CONFIG.bucket })); console.log(`Deleted test bucket: ${TEST_CONFIG.bucket}`); } catch (error) { console.error('Failed to cleanup S3 bucket:', error); } // Clean up test storage await fs.rm(testStoragePath, { recursive: true, force: true }); // Restore environment process.env = originalEnv; // Close database await db.destroy(); }); beforeEach(async () => { if (process.env.SKIP_S3_TESTS === 'true') { return; } // Clean backup tables await db('backup_runs').del(); await db('backup_file_states').del(); await db('database_backup_runs').del(); // Configure S3 backup settings await configureS3Backup(); }); afterEach(async () => { if (process.env.SKIP_S3_TESTS === 'true') return; // Clean up S3 objects created during test await cleanupS3Bucket(); }); describe('S3 Connection and Configuration', () => { it('should successfully connect to S3-compatible storage', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; const s3Adapter = new S3StorageAdapter({ ...TEST_CONFIG, bucket: TEST_CONFIG.bucket, forcePathStyle: true, sslEnabled: false }); const connected = await s3Adapter.testConnection(); expect(connected).toBe(true); }); it('should validate S3 configuration before backup', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; // Remove required configuration await db('app_settings') .where('setting_key', 'backup_s3_secret_key') .del(); await backupService.runBackup(); const lastRun = await db('backup_runs') .orderBy('started_at', 'desc') .first(); expect(lastRun.status).toBe('failed'); expect(lastRun.error_message).toContain('S3 backup configuration incomplete'); }); }); describe('Full S3 Backup Process', () => { it('should perform complete S3 backup with all file types', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; // Run backup await backupService.runBackup(); // Verify backup run completed const backupRun = await db('backup_runs') .orderBy('started_at', 'desc') .first(); expect(backupRun.status).toBe('completed'); expect(backupRun.files_backed_up).toBeGreaterThan(0); expect(backupRun.total_size_bytes).toBeGreaterThan(0); // Verify files in S3 const s3Objects = await listS3Objects(); expect(s3Objects.length).toBeGreaterThan(0); // Check for expected file types const hasPhotos = s3Objects.some(obj => obj.Key.includes('events/active')); const hasThumbnails = s3Objects.some(obj => obj.Key.includes('thumbnails')); const hasManifest = s3Objects.some(obj => obj.Key.includes('backup-manifest')); const hasSummary = s3Objects.some(obj => obj.Key.includes('backup-summary.json')); expect(hasPhotos).toBe(true); expect(hasThumbnails).toBe(true); expect(hasManifest).toBe(true); expect(hasSummary).toBe(true); }); it('should handle large file uploads with multipart', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; // Create a large test file (15MB) const largeFilePath = path.join(testStoragePath, 'events/active/large-photo.jpg'); const largeFileSize = 15 * 1024 * 1024; // 15MB const largeFileContent = Buffer.alloc(largeFileSize, 'x'); await fs.writeFile(largeFilePath, largeFileContent); // Run backup await backupService.runBackup(); // Verify large file was uploaded const s3Objects = await listS3Objects(); const largeFileUploaded = s3Objects.some(obj => obj.Key.includes('large-photo.jpg') && obj.Size === largeFileSize ); expect(largeFileUploaded).toBe(true); }); it('should include database backup when available', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; // Create a mock database backup const dbBackupPath = path.join(testStoragePath, 'backups/db-backup.sql'); await fs.mkdir(path.dirname(dbBackupPath), { recursive: true }); await fs.writeFile(dbBackupPath, 'CREATE TABLE test (id INT);'); // Record database backup await db('database_backup_runs').insert({ started_at: new Date(), completed_at: new Date(), status: 'completed', backup_type: 'sqlite', file_path: dbBackupPath, file_size_bytes: 100, checksum: 'test123', statistics: JSON.stringify({ tables: {} }), table_checksums: JSON.stringify({}) }); // Configure to include database await db('app_settings') .where('setting_key', 'backup_include_database') .update({ setting_value: 'true' }); // Run backup await backupService.runBackup(); // Verify database backup in S3 const s3Objects = await listS3Objects(); const hasDbBackup = s3Objects.some(obj => obj.Key.includes('database/db-backup.sql')); expect(hasDbBackup).toBe(true); }); }); describe('Incremental Backup', () => { it('should only upload changed files in incremental backup', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; // First backup - full await backupService.runBackup(); const firstRun = await db('backup_runs') .orderBy('started_at', 'desc') .first(); const firstObjectCount = (await listS3Objects()).length; // Wait a moment to ensure different timestamps await new Promise(resolve => setTimeout(resolve, 100)); // Modify one file const modifiedFile = path.join(testStoragePath, 'events/active/event1/photo1.jpg'); await fs.writeFile(modifiedFile, 'modified content'); // Second backup - incremental await backupService.runBackup(); const secondRun = await db('backup_runs') .orderBy('started_at', 'desc') .first(); expect(secondRun.id).not.toBe(firstRun.id); expect(secondRun.files_backed_up).toBe(1); // Only modified file // Check manifest indicates incremental if (secondRun.manifest_path) { const manifest = await backupService.getBackupManifest(secondRun.id); expect(manifest.manifest.incremental).toBeDefined(); expect(manifest.manifest.incremental.modified_files_count).toBe(1); } }); it('should track file states across backups', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; await backupService.runBackup(); // Check file states are recorded const fileStates = await db('backup_file_states').select('*'); expect(fileStates.length).toBeGreaterThan(0); // Verify checksums are stored const hasChecksums = fileStates.every(state => state.checksum !== null); expect(hasChecksums).toBe(true); }); }); describe('S3 Manifest Storage', () => { it('should upload manifest to S3 and retrieve it', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; // Configure YAML manifest format await db('app_settings') .where('setting_key', 'backup_manifest_format') .update({ setting_value: '"yaml"' }); await backupService.runBackup(); const backupRun = await db('backup_runs') .orderBy('started_at', 'desc') .first(); expect(backupRun.manifest_path).toMatch(/^s3:\/\//); // Retrieve manifest const { manifest, summary } = await backupService.getBackupManifest(backupRun.id); expect(manifest).toBeDefined(); expect(manifest.backup.id).toBeDefined(); expect(summary).toContain('BACKUP MANIFEST SUMMARY'); }); it('should validate manifest integrity', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; await backupService.runBackup(); const backupRun = await db('backup_runs') .orderBy('started_at', 'desc') .first(); const validationResult = await backupService.validateBackupManifest(backupRun.manifest_path); expect(validationResult.valid).toBe(true); expect(validationResult.manifest).toBeDefined(); }); }); describe('Error Recovery', () => { it('should handle S3 connection failures gracefully', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; // Configure with invalid endpoint await db('app_settings') .where('setting_key', 'backup_s3_endpoint') .update({ setting_value: '"http://invalid-endpoint:9999"' }); await backupService.runBackup(); const backupRun = await db('backup_runs') .orderBy('started_at', 'desc') .first(); expect(backupRun.status).toBe('failed'); expect(backupRun.error_message).toBeDefined(); }); it('should continue backup despite individual file failures', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; // Create a file that will be deleted during backup const tempFile = path.join(testStoragePath, 'events/active/temp.jpg'); await fs.writeFile(tempFile, 'temporary'); // Mock file deletion during backup const originalUpload = S3StorageAdapter.prototype.upload; let callCount = 0; S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) { callCount++; if (callCount === 2) { // Delete the temp file to cause an error await fs.unlink(tempFile).catch(() => {}); } return originalUpload.call(this, localPath, s3Key, options); }); await backupService.runBackup(); const backupRun = await db('backup_runs') .orderBy('started_at', 'desc') .first(); // Should complete despite one file error expect(backupRun.status).toBe('completed'); expect(backupRun.files_backed_up).toBeGreaterThan(0); // Restore original method S3StorageAdapter.prototype.upload = originalUpload; }); it('should retry failed uploads with exponential backoff', async () => { if (process.env.SKIP_S3_TESTS === 'true') return; // Mock S3 upload to fail twice then succeed const originalUpload = S3StorageAdapter.prototype.upload; let attemptCount = 0; S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) { attemptCount++; if (attemptCount <= 2) { const error = new Error('Network timeout'); error.code = 'ETIMEDOUT'; throw error; } return originalUpload.call(this, localPath, s3Key, options); }); await backupService.runBackup(); const backupRun = await db('backup_runs') .orderBy('started_at', 'desc') .first(); // Should succeed after retries expect(backupRun.status).toBe('completed'); expect(attemptCount).toBeGreaterThan(2); // Restore original method S3StorageAdapter.prototype.upload = originalUpload; }); }); // Helper functions async function setupTestData() { // Create test directory structure const dirs = [ 'events/active/event1', 'events/active/event2', 'events/archived', 'thumbnails', 'uploads' ]; for (const dir of dirs) { await fs.mkdir(path.join(testStoragePath, dir), { recursive: true }); } // Create test files const files = [ { path: 'events/active/event1/photo1.jpg', content: 'photo1 content' }, { path: 'events/active/event1/photo2.jpg', content: 'photo2 content' }, { path: 'events/active/event2/photo3.jpg', content: 'photo3 content' }, { path: 'events/archived/old-event.zip', content: 'archived content' }, { path: 'thumbnails/thumb1.jpg', content: 'thumbnail content' }, { path: 'uploads/logo.png', content: 'logo content' } ]; for (const file of files) { await fs.writeFile( path.join(testStoragePath, file.path), file.content ); } } async function configureS3Backup() { const settings = [ { setting_key: 'backup_enabled', setting_value: 'true' }, { setting_key: 'backup_destination_type', setting_value: '"s3"' }, { setting_key: 'backup_s3_bucket', setting_value: `"${TEST_CONFIG.bucket}"` }, { setting_key: 'backup_s3_region', setting_value: `"${TEST_CONFIG.region}"` }, { setting_key: 'backup_s3_endpoint', setting_value: `"${TEST_CONFIG.endpoint}"` }, { setting_key: 'backup_s3_access_key', setting_value: `"${TEST_CONFIG.accessKeyId}"` }, { setting_key: 'backup_s3_secret_key', setting_value: `"${TEST_CONFIG.secretAccessKey}"` }, { setting_key: 'backup_s3_force_path_style', setting_value: 'true' }, { setting_key: 'backup_s3_ssl_enabled', setting_value: 'false' }, { setting_key: 'backup_include_archived', setting_value: 'true' }, { setting_key: 'backup_incremental', setting_value: 'true' }, { setting_key: 'backup_max_file_size_mb', setting_value: '100' } ]; for (const setting of settings) { await db('app_settings') .insert({ setting_type: 'backup', ...setting, created_at: new Date(), updated_at: new Date() }) .onConflict(['setting_type', 'setting_key']) .merge(); } } async function listS3Objects() { const response = await s3Client.send(new ListObjectsV2Command({ Bucket: TEST_CONFIG.bucket })); return response.Contents || []; } async function cleanupS3Bucket() { try { const objects = await listS3Objects(); if (objects.length > 0) { await s3Client.send(new DeleteObjectsCommand({ Bucket: TEST_CONFIG.bucket, Delete: { Objects: objects.map(obj => ({ Key: obj.Key })) } })); } } catch (error) { console.error('Failed to cleanup S3 objects:', error); } } });