fix(backups): enforce authorization and safe retention

- Gate volume backup retention and S3 controls by update permission
- Preserve backup records when S3 deletion fails
- Share SFTP download streaming with consistent missing-file handling
- Handle schedule creation errors and link service database backups
This commit is contained in:
Andras Bacsai committed 2026-07-19 23:15:55 +02:00
1 parent 99a8a96e7f
commit 72a0a57f0e
13 files changed
+433 -112

No files matched your search

+120
View File
@@ -19,11 +19,15 @@ use App\Models\S3Storage;
use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\RedirectResponse;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@@ -45,6 +49,12 @@ it('provides the volume backup domain classes and relationship', function () {
->and(method_exists(LocalFileVolume::class, 'scheduledBackups'))->toBeTrue();
});
it('keeps the volume backup script inside the Livewire root element', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups.blade.php'));
expect(strrpos($view, '@endscript'))->toBeLessThan(strrpos($view, '</div>'));
});
it('targets named volumes and application directory mounts through one backup relation', function () {
$team = Team::factory()->create();
[$application, $volume] = createVolumeBackupApplication($team);
@@ -117,6 +127,59 @@ it('creates a scheduled backup with a preselected volume from the shared modal',
->and($backup->s3_storage_id)->toBeNull();
});
it('handles scheduled backup persistence failures', function () {
$team = Team::factory()->create();
signInForVolumeBackups($this, $team);
[$application, $volume] = createVolumeBackupApplication($team);
$shouldFail = true;
ScheduledVolumeBackup::creating(function () use (&$shouldFail): void {
if ($shouldFail) {
$shouldFail = false;
throw new RuntimeException('Scheduled backup persistence failed.');
}
});
Livewire::test(CreateScheduledVolumeBackup::class, [
'application' => $application,
'selectedTargetKey' => 'volume:'.$volume->id,
])
->set('frequency', 'daily')
->call('submit')
->assertDispatched('error', 'Scheduled backup persistence failed.');
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
it('respects the instance wire navigate setting after creating a scheduled backup', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::create([
'id' => 0,
'is_wire_navigate_enabled' => false,
]));
$team = Team::factory()->create();
signInForVolumeBackups($this, $team);
[$application, $volume] = createVolumeBackupApplication($team);
$component = Livewire::test(CreateScheduledVolumeBackup::class, [
'application' => $application,
'selectedTargetKey' => 'volume:'.$volume->id,
])
->set('frequency', 'daily')
->call('submit');
$backup = ScheduledVolumeBackup::query()->sole();
$component->assertRedirectToRoute('project.application.backup.show', [
'project_uuid' => $application->project()->uuid,
'environment_uuid' => $application->environment->uuid,
'application_uuid' => $application->uuid,
'backup_uuid' => $backup->uuid,
]);
expect($component->effects)->not->toHaveKey('redirectUsingNavigate');
});
it('creates a scheduled backup for a preselected application directory', function () {
$team = Team::factory()->create();
signInForVolumeBackups($this, $team);
@@ -411,6 +474,49 @@ it('offers backup configuration and status on application directory mounts', fun
->assertSee('href="'.$backupUrl.'"', false);
});
it('links the backup enabled badge to service database backups for database directory mounts', function () {
$team = Team::factory()->create();
signInForVolumeBackups($this, $team);
[$application] = createVolumeBackupApplication($team);
$service = Service::factory()->create([
'environment_id' => $application->environment_id,
'destination_id' => $application->destination_id,
'destination_type' => $application->destination_type,
]);
$database = ServiceDatabase::create([
'uuid' => new_public_id(),
'name' => 'postgres',
'image' => 'postgres:17-alpine',
'service_id' => $service->id,
]);
$directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
'uuid' => new_public_id(),
'fs_path' => './postgres-data',
'mount_path' => '/var/lib/postgresql/data',
'is_directory' => true,
'is_based_on_git' => false,
'is_preview_suffix_enabled' => true,
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
])));
$directory->scheduledBackups()->create([
'team_id' => $team->id,
'frequency' => 'daily',
'enabled' => true,
]);
$backupUrl = route('project.service.database.backups', [
'project_uuid' => $application->project()->uuid,
'environment_uuid' => $application->environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $database->uuid,
]);
Livewire::test(FileStorage::class, ['fileStorage' => $directory])
->assertSee('Backup enabled')
->assertSee('href="'.$backupUrl.'"', false);
});
it('prevents a backed up directory from being converted or deleted', function () {
Process::fake();
$team = Team::factory()->create();
@@ -484,6 +590,20 @@ it('exposes actions to manage and run volume backups', function () {
->and(method_exists(VolumeBackups::class, 'deleteBackup'))->toBeTrue();
});
it('declares the volume backup action return types', function () {
$backupNowReturnType = (new ReflectionMethod(VolumeBackups::class, 'backupNow'))->getReturnType();
$deleteReturnType = (new ReflectionMethod(VolumeBackups::class, 'delete'))->getReturnType();
expect($backupNowReturnType)->toBeInstanceOf(ReflectionUnionType::class)
->and(collect($backupNowReturnType->getTypes())->map->getName()->all())->toEqualCanonicalizing([
RedirectResponse::class,
Redirector::class,
'null',
])
->and($deleteReturnType)->toBeInstanceOf(ReflectionUnionType::class)
->and(collect($deleteReturnType->getTypes())->map->getName()->all())->toEqualCanonicalizing(['bool', 'string']);
});
it('renders volume backup executions like database backup executions', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
$team = Team::factory()->create();