Store s3_storage_id on scheduled volume backup executions so retention and recovery use the S3 that received the upload. Extract DeleteScheduledVolumeBackup for UI and resource deletion, and isolate database backup retention failures so cleanup errors do not fail the job.
324 lines
11 KiB
PHP
324 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Jobs\DatabaseBackupJob;
|
|
use App\Models\S3Storage;
|
|
use App\Models\ScheduledDatabaseBackup;
|
|
use App\Models\ScheduledDatabaseBackupExecution;
|
|
use App\Models\Team;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
test('scheduled_database_backup_executions table has s3_uploaded column', function () {
|
|
expect(Schema::hasColumn('scheduled_database_backup_executions', 's3_uploaded'))->toBeTrue();
|
|
});
|
|
|
|
test('s3_uploaded column is nullable', function () {
|
|
$columns = Schema::getColumns('scheduled_database_backup_executions');
|
|
$s3UploadedColumn = collect($columns)->firstWhere('name', 's3_uploaded');
|
|
|
|
expect($s3UploadedColumn)->not->toBeNull();
|
|
expect($s3UploadedColumn['nullable'])->toBeTrue();
|
|
});
|
|
|
|
test('scheduled database backup execution model casts s3_uploaded correctly', function () {
|
|
$model = new ScheduledDatabaseBackupExecution;
|
|
$casts = $model->getCasts();
|
|
|
|
expect($casts)->toHaveKey('s3_uploaded');
|
|
expect($casts['s3_uploaded'])->toBe('boolean');
|
|
});
|
|
|
|
test('scheduled database backup execution model casts storage deletion fields correctly', function () {
|
|
$model = new ScheduledDatabaseBackupExecution;
|
|
$casts = $model->getCasts();
|
|
|
|
expect($casts)->toHaveKey('local_storage_deleted');
|
|
expect($casts['local_storage_deleted'])->toBe('boolean');
|
|
expect($casts)->toHaveKey('s3_storage_deleted');
|
|
expect($casts['s3_storage_deleted'])->toBe('boolean');
|
|
});
|
|
|
|
test('upload_to_s3 throws exception and disables s3 when storage is null', function () {
|
|
$backup = ScheduledDatabaseBackup::create([
|
|
'frequency' => '0 0 * * *',
|
|
'save_s3' => true,
|
|
's3_storage_id' => 99999,
|
|
'database_type' => 'App\Models\StandalonePostgresql',
|
|
'database_id' => 1,
|
|
'team_id' => Team::factory()->create()->id,
|
|
]);
|
|
|
|
$job = new DatabaseBackupJob($backup);
|
|
|
|
$reflection = new ReflectionClass($job);
|
|
$s3Property = $reflection->getProperty('s3');
|
|
$s3Property->setValue($job, null);
|
|
|
|
$method = $reflection->getMethod('upload_to_s3');
|
|
|
|
expect(fn () => $method->invoke($job))
|
|
->toThrow(Exception::class, 'S3 storage configuration is missing or has been deleted');
|
|
|
|
$backup->refresh();
|
|
expect($backup->save_s3)->toBeFalsy();
|
|
expect($backup->s3_storage_id)->toBeNull();
|
|
});
|
|
|
|
test('upload_to_s3 exception message reports the previous s3 storage id', function () {
|
|
$backup = ScheduledDatabaseBackup::create([
|
|
'frequency' => '0 0 * * *',
|
|
'save_s3' => true,
|
|
's3_storage_id' => 12345,
|
|
'database_type' => 'App\Models\StandalonePostgresql',
|
|
'database_id' => 1,
|
|
'team_id' => Team::factory()->create()->id,
|
|
]);
|
|
|
|
$job = new DatabaseBackupJob($backup);
|
|
|
|
$reflection = new ReflectionClass($job);
|
|
$reflection->getProperty('s3')->setValue($job, null);
|
|
|
|
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
|
|
->toThrow(Exception::class, 'S3 storage ID: 12345');
|
|
|
|
$backup->refresh();
|
|
expect($backup->save_s3)->toBeFalsy();
|
|
expect($backup->s3_storage_id)->toBeNull();
|
|
});
|
|
|
|
test('upload_to_s3 exception message reports null when no previous s3 storage id exists', function () {
|
|
$backup = ScheduledDatabaseBackup::create([
|
|
'frequency' => '0 0 * * *',
|
|
'save_s3' => true,
|
|
's3_storage_id' => null,
|
|
'database_type' => 'App\Models\StandalonePostgresql',
|
|
'database_id' => 1,
|
|
'team_id' => Team::factory()->create()->id,
|
|
]);
|
|
|
|
$job = new DatabaseBackupJob($backup);
|
|
|
|
$reflection = new ReflectionClass($job);
|
|
$reflection->getProperty('s3')->setValue($job, null);
|
|
|
|
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
|
|
->toThrow(Exception::class, 'S3 storage ID: null');
|
|
});
|
|
|
|
test('deleting s3 storage disables s3 on linked backups', function () {
|
|
$team = Team::factory()->create();
|
|
|
|
$s3 = S3Storage::create([
|
|
'name' => 'Test S3',
|
|
'region' => 'us-east-1',
|
|
'key' => 'test-key',
|
|
'secret' => 'test-secret',
|
|
'bucket' => 'test-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
$backup1 = ScheduledDatabaseBackup::create([
|
|
'frequency' => '0 0 * * *',
|
|
'save_s3' => true,
|
|
's3_storage_id' => $s3->id,
|
|
'database_type' => 'App\Models\StandalonePostgresql',
|
|
'database_id' => 1,
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
$backup2 = ScheduledDatabaseBackup::create([
|
|
'frequency' => '0 0 * * *',
|
|
'save_s3' => true,
|
|
's3_storage_id' => $s3->id,
|
|
'database_type' => 'App\Models\StandaloneMysql',
|
|
'database_id' => 2,
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
// Unrelated backup should not be affected
|
|
$unrelatedBackup = ScheduledDatabaseBackup::create([
|
|
'frequency' => '0 0 * * *',
|
|
'save_s3' => true,
|
|
's3_storage_id' => null,
|
|
'database_type' => 'App\Models\StandalonePostgresql',
|
|
'database_id' => 3,
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
$s3->delete();
|
|
|
|
$backup1->refresh();
|
|
$backup2->refresh();
|
|
$unrelatedBackup->refresh();
|
|
|
|
expect($backup1->save_s3)->toBeFalsy();
|
|
expect($backup1->s3_storage_id)->toBeNull();
|
|
expect($backup2->save_s3)->toBeFalsy();
|
|
expect($backup2->s3_storage_id)->toBeNull();
|
|
expect($unrelatedBackup->save_s3)->toBeTruthy();
|
|
});
|
|
|
|
test('failed method does not overwrite successful backup status', function () {
|
|
$team = Team::factory()->create();
|
|
|
|
$backup = ScheduledDatabaseBackup::create([
|
|
'frequency' => '0 0 * * *',
|
|
'save_s3' => false,
|
|
'database_type' => 'App\Models\StandalonePostgresql',
|
|
'database_id' => 1,
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
$log = ScheduledDatabaseBackupExecution::create([
|
|
'uuid' => 'test-uuid-success-guard',
|
|
'database_name' => 'test_db',
|
|
'filename' => '/backup/test.dmp',
|
|
'scheduled_database_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'message' => 'Backup completed successfully',
|
|
'size' => 1024,
|
|
]);
|
|
|
|
$job = new DatabaseBackupJob($backup);
|
|
|
|
$reflection = new ReflectionClass($job);
|
|
|
|
$teamProp = $reflection->getProperty('team');
|
|
$teamProp->setValue($job, $team);
|
|
|
|
$logUuidProp = $reflection->getProperty('backup_log_uuid');
|
|
$logUuidProp->setValue($job, 'test-uuid-success-guard');
|
|
|
|
// Simulate a post-backup failure (e.g. notification error)
|
|
$job->failed(new Exception('Request to the Resend API failed'));
|
|
|
|
$log->refresh();
|
|
expect($log->status)->toBe('success');
|
|
expect($log->message)->toBe('Backup completed successfully');
|
|
expect($log->size)->toBe(1024);
|
|
});
|
|
|
|
test('failed method updates status when backup was not successful', function () {
|
|
$team = Team::factory()->create();
|
|
|
|
$backup = ScheduledDatabaseBackup::create([
|
|
'frequency' => '0 0 * * *',
|
|
'save_s3' => false,
|
|
'database_type' => 'App\Models\StandalonePostgresql',
|
|
'database_id' => 1,
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
$log = ScheduledDatabaseBackupExecution::create([
|
|
'uuid' => 'test-uuid-pending-guard',
|
|
'database_name' => 'test_db',
|
|
'filename' => '/backup/test.dmp',
|
|
'scheduled_database_backup_id' => $backup->id,
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$job = new DatabaseBackupJob($backup);
|
|
|
|
$reflection = new ReflectionClass($job);
|
|
|
|
$teamProp = $reflection->getProperty('team');
|
|
$teamProp->setValue($job, $team);
|
|
|
|
$logUuidProp = $reflection->getProperty('backup_log_uuid');
|
|
$logUuidProp->setValue($job, 'test-uuid-pending-guard');
|
|
|
|
$job->failed(new Exception('Some real failure'));
|
|
|
|
$log->refresh();
|
|
expect($log->status)->toBe('failed');
|
|
expect($log->message)->toContain('Some real failure');
|
|
});
|
|
|
|
test('retention cleanup failure does not fail a successful database backup', function () {
|
|
$team = Team::factory()->create();
|
|
$s3 = S3Storage::create([
|
|
'name' => 'Test S3',
|
|
'region' => 'us-east-1',
|
|
'key' => 'test-key',
|
|
'secret' => 'test-secret',
|
|
'bucket' => 'test-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
]);
|
|
$backup = ScheduledDatabaseBackup::create([
|
|
'frequency' => '0 0 * * *',
|
|
'save_s3' => true,
|
|
'disable_local_backup' => true,
|
|
's3_storage_id' => $s3->id,
|
|
'database_type' => 'App\Models\StandalonePostgresql',
|
|
'database_id' => 1,
|
|
'team_id' => $team->id,
|
|
'database_backup_retention_amount_s3' => 1,
|
|
]);
|
|
$oldExecution = ScheduledDatabaseBackupExecution::create([
|
|
'uuid' => 'old-backup',
|
|
'database_name' => 'database',
|
|
'filename' => '/backup/old.dmp',
|
|
'scheduled_database_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
's3_uploaded' => true,
|
|
'local_storage_deleted' => true,
|
|
'created_at' => now()->subDay(),
|
|
]);
|
|
ScheduledDatabaseBackupExecution::create([
|
|
'uuid' => 'new-backup',
|
|
'database_name' => 'database',
|
|
'filename' => '/backup/new.dmp',
|
|
'scheduled_database_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
's3_uploaded' => true,
|
|
'local_storage_deleted' => true,
|
|
]);
|
|
$disk = Mockery::mock();
|
|
$disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse();
|
|
Storage::shouldReceive('build')->once()->andReturn($disk);
|
|
$job = new DatabaseBackupJob($backup);
|
|
|
|
expect(fn () => (new ReflectionClass($job))->getMethod('removeExpiredBackups')->invoke($job))
|
|
->not->toThrow(Throwable::class);
|
|
|
|
expect($oldExecution->fresh()->s3_storage_deleted)->toBeFalse();
|
|
});
|
|
|
|
test('s3 storage has scheduled backups relationship', function () {
|
|
$team = Team::factory()->create();
|
|
|
|
$s3 = S3Storage::create([
|
|
'name' => 'Test S3',
|
|
'region' => 'us-east-1',
|
|
'key' => 'test-key',
|
|
'secret' => 'test-secret',
|
|
'bucket' => 'test-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
ScheduledDatabaseBackup::create([
|
|
'frequency' => '0 0 * * *',
|
|
'save_s3' => true,
|
|
's3_storage_id' => $s3->id,
|
|
'database_type' => 'App\Models\StandalonePostgresql',
|
|
'database_id' => 1,
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
expect($s3->scheduledBackups()->count())->toBe(1);
|
|
});
|
|
|
|
test('database backup job escapes the S3 copy destination argument', function () {
|
|
$source = file_get_contents(app_path('Jobs/DatabaseBackupJob.php'));
|
|
|
|
expect($source)->toContain('$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");')
|
|
->and($source)->toContain('mc cp {$escapedBackupLocation} {$escapedS3Destination}')
|
|
->and($source)->not->toContain('mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/');
|
|
});
|