507 lines
16 KiB
JavaScript
507 lines
16 KiB
JavaScript
const { describe, it, expect, 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);
|
|
}
|
|
}
|
|
});
|