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

@@ -2,12 +2,14 @@
use App\Jobs\CleanupInstanceStuffsJob;
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\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
@@ -235,6 +237,55 @@ test('deleteOldBackupsLocally identifies correct backups for deletion by retenti
expect($successfulBackups->first()->uuid)->toBe('exec-uuid-1');
});
test('database S3 retention remains best effort when deletion fails', 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,
]);
$newestExecution = ScheduledDatabaseBackupExecution::create([
'uuid' => 'newest-s3-backup',
'database_name' => 'test_db',
'filename' => '/backup/newest.dmp',
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
's3_uploaded' => true,
]);
$oldExecution = ScheduledDatabaseBackupExecution::create([
'uuid' => 'old-s3-backup',
'database_name' => 'test_db',
'filename' => '/backup/old.dmp',
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
's3_uploaded' => true,
]);
ScheduledDatabaseBackupExecution::whereKey($newestExecution->id)->update(['created_at' => now()]);
ScheduledDatabaseBackupExecution::whereKey($oldExecution->id)->update(['created_at' => now()->subDay()]);
$disk = Mockery::mock();
$disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse();
Storage::shouldReceive('build')->once()->andReturn($disk);
removeOldBackups($backup);
expect($oldExecution->fresh()->s3_storage_deleted)->toBeFalse()
->and($backup->executions()->count())->toBe(2);
});
test('cleanup instance stuffs job throttles retention enforcement via cache', function () {
Cache::forget('backup-retention-enforcement');
@@ -28,3 +28,35 @@ it('keeps mutable Livewire components behind authorization checks', function (st
['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"],
],
]);
it('authorizes every volume backup form control', function (string $path, array $controlPatterns) {
$source = file_get_contents(base_path($path));
foreach ($controlPatterns as $controlPattern) {
expect($source)->toMatch($controlPattern);
}
})->with([
'retention controls' => [
'resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php',
[
'/<x-forms\.button(?=[^>]*type="submit")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Save<\/x-forms\.button>/s',
'/<x-forms\.input(?=[^>]*id="retentionAmountLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
'/<x-forms\.input(?=[^>]*id="retentionDaysLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
'/<x-forms\.input(?=[^>]*id="retentionMaxStorageLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
'/<x-forms\.input(?=[^>]*id="retentionAmountS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
'/<x-forms\.input(?=[^>]*id="retentionDaysS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
'/<x-forms\.input(?=[^>]*id="retentionMaxStorageS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
],
],
'S3 controls' => [
'resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php',
[
'/<x-forms\.button(?=[^>]*type="submit")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Save<\/x-forms\.button>/s',
'/<x-forms\.button(?=[^>]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Enable S3<\/x-forms\.button>/s',
'/<x-forms\.button(?=[^>]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Disable S3<\/x-forms\.button>/s',
'/<x-forms\.select(?=[^>]*id="s3StorageId")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>/s',
'/<x-forms\.checkbox(?=[^>]*id="disableLocalBackup")(?=[^>]*instantSave)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
'/<x-forms\.checkbox(?=[^>]*id="disableLocalBackup")(?=[^>]*disabled)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
],
],
]);
+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();
+72
View File
@@ -0,0 +1,72 @@
<?php
use App\Models\PrivateKey;
use App\Models\Server;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
use Symfony\Component\HttpFoundation\StreamedResponse;
function backupDownloadServer(): Server
{
$server = new Server;
$server->forceFill([
'ip' => '192.0.2.10',
'port' => 2222,
'user' => 'root',
]);
$privateKey = Mockery::mock(PrivateKey::class);
$privateKey->shouldReceive('getKeyLocation')->andReturn('/tmp/private-key');
$server->setRelation('privateKey', $privateKey);
return $server;
}
it('throws when the backup file does not exist on the server', function () {
$disk = Mockery::mock();
$disk->shouldReceive('exists')
->once()
->with('/backups/archive.tar.gz')
->andReturnFalse();
Storage::shouldReceive('build')
->once()
->with([
'driver' => 'sftp',
'host' => '192.0.2.10',
'port' => 2222,
'username' => 'root',
'privateKey' => '/tmp/private-key',
'root' => '/',
])
->andReturn($disk);
streamBackupFromServer(backupDownloadServer(), '/backups/archive.tar.gz', 'application/gzip');
})->throws(FileNotFoundException::class);
it('streams a backup file with the requested content type', function () {
$stream = fopen('php://memory', 'r+');
fwrite($stream, 'backup contents');
rewind($stream);
$disk = Mockery::mock();
$disk->shouldReceive('exists')->once()->andReturnTrue();
$disk->shouldReceive('readStream')
->once()
->with('/backups/archive.tar.gz')
->andReturn($stream);
Storage::shouldReceive('build')->once()->andReturn($disk);
$response = streamBackupFromServer(backupDownloadServer(), '/backups/archive.tar.gz', 'application/gzip');
expect($response)->toBeInstanceOf(StreamedResponse::class)
->and($response->headers->get('content-type'))->toBe('application/gzip')
->and($response->headers->get('content-disposition'))->toBe('attachment; filename="archive.tar.gz"');
ob_start();
ob_start();
$response->sendContent();
$contents = ob_get_clean();
expect($contents)->toBe('backup contents');
});