From 72a0a57f0e9fc7e152ab3051a7aec71d7d200660 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:15:55 +0200 Subject: [PATCH] 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 --- .../Api/VolumeBackupsController.php | 42 +++++- .../Project/Application/Backup/Create.php | 34 ++--- app/Livewire/Project/Service/FileStorage.php | 19 ++- .../Project/Shared/Storages/VolumeBackups.php | 8 +- bootstrap/helpers/databases.php | 47 ++++++- .../shared/storages/volume-backups.blade.php | 16 +-- .../volume-backups/retention.blade.php | 13 +- .../storages/volume-backups/s3.blade.php | 16 ++- routes/web.php | 75 ++--------- .../BackupRetentionAndStaleDetectionTest.php | 51 ++++++++ ...bleLivewireComponentsAuthorizationTest.php | 32 +++++ tests/Feature/VolumeBackupTest.php | 120 ++++++++++++++++++ tests/Unit/BackupDownloadHelperTest.php | 72 +++++++++++ 13 files changed, 433 insertions(+), 112 deletions(-) create mode 100644 tests/Unit/BackupDownloadHelperTest.php diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index fdd8caf13..e30cdad38 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\MessageBag; use OpenApi\Attributes as OA; use RuntimeException; @@ -161,6 +162,26 @@ public function upsert(Request $request): JsonResponse return response()->json(['message' => 'Storage not found.'], 404); } + ['errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId); + + if ($errors->isNotEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + return $this->persistSchedule($request, $storage, $teamId, $s3Storage, $saveToS3, $resourceType, $resource); + } + + /** + * @return array{errors: MessageBag, s3Storage: S3Storage|null, saveToS3: bool} + */ + private function validateUpsertRequest( + Request $request, + LocalPersistentVolume|LocalFileVolume $storage, + int|string $teamId, + ): array { $validator = customApiValidator($request->all(), [ 'frequency' => 'required|string|max:255', 'enabled' => 'boolean', @@ -223,13 +244,22 @@ public function upsert(Request $request): JsonResponse $errors->add('storage_uuid', 'Only directory file storages can be backed up.'); } - if ($errors->isNotEmpty()) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => $errors, - ], 422); - } + return [ + 'errors' => $errors, + 's3Storage' => $s3Storage, + 'saveToS3' => $saveToS3, + ]; + } + private function persistSchedule( + Request $request, + LocalPersistentVolume|LocalFileVolume $storage, + int|string $teamId, + ?S3Storage $s3Storage, + bool $saveToS3, + string $resourceType, + Model $resource, + ): JsonResponse { $backup = $storage->scheduledBackups()->updateOrCreate([], [ 'team_id' => $teamId, 'frequency' => $request->string('frequency')->toString(), diff --git a/app/Livewire/Project/Application/Backup/Create.php b/app/Livewire/Project/Application/Backup/Create.php index 6c22bd7f3..4e46ba779 100644 --- a/app/Livewire/Project/Application/Backup/Create.php +++ b/app/Livewire/Project/Application/Backup/Create.php @@ -85,22 +85,26 @@ public function submit(): void return; } - $backup = $target->scheduledBackups()->updateOrCreate( - [], - [ - 'team_id' => currentTeam()->id, - 'frequency' => $this->frequency, - 'enabled' => true, - ], - ); + try { + $backup = $target->scheduledBackups()->updateOrCreate( + [], + [ + 'team_id' => currentTeam()->id, + 'frequency' => $this->frequency, + 'enabled' => true, + ], + ); - $this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.'); - $this->redirectRoute('project.application.backup.show', [ - 'project_uuid' => $this->application->project()->uuid, - 'environment_uuid' => $this->application->environment->uuid, - 'application_uuid' => $this->application->uuid, - 'backup_uuid' => $backup->uuid, - ], navigate: true); + $this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.'); + redirectRoute($this, 'project.application.backup.show', [ + 'project_uuid' => $this->application->project()->uuid, + 'environment_uuid' => $this->application->environment->uuid, + 'application_uuid' => $this->application->uuid, + 'backup_uuid' => $backup->uuid, + ]); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function render() diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 31501e4cf..84a0daec8 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -84,7 +84,24 @@ public function refreshBackupStatus(): void $this->hasEnabledBackup = $backup?->enabled ?? false; $this->backupUrl = null; - if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) { + if (! $this->hasEnabledBackup) { + return; + } + + if ($this->resource instanceof ServiceDatabase) { + $this->backupUrl = route('project.service.database.backups', [ + 'project_uuid' => $this->resource->service->project()->uuid, + 'environment_uuid' => $this->resource->service->environment->uuid, + 'service_uuid' => $this->resource->service->uuid, + 'stack_service_uuid' => $this->resource->uuid, + ]); + + return; + } + + if (! $this->resource instanceof Application) { + $this->hasEnabledBackup = false; + return; } diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index 56301cf4e..3e56c4086 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -9,6 +9,8 @@ use App\Models\S3Storage; use App\Models\ScheduledVolumeBackup; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\RedirectResponse; +use Illuminate\Routing\Redirector; use Illuminate\Support\Collection; use Livewire\Component; use Livewire\WithPagination; @@ -178,13 +180,13 @@ public function toggleEnabled(): void $this->dispatch('success', $this->enabled ? 'Storage backups enabled.' : 'Storage backups disabled.'); } - public function backupNow() + public function backupNow(): Redirector|RedirectResponse|null { $this->authorize('update', $this->resource); if (! $this->backup) { if (! $this->validateSettings()) { - return; + return null; } $this->enabled = false; @@ -202,7 +204,7 @@ public function backupNow() ]); } - public function delete(?string $password = null, array $selectedActions = []) + public function delete(?string $password = null, array $selectedActions = []): bool|string { $this->authorize('update', $this->resource); diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 4aeda9dbf..8f4f373b7 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -17,6 +17,10 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use RuntimeException; +use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Throwable; function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql { @@ -194,6 +198,41 @@ function deleteBackupsLocally(string|array|null $filenames, Server $server, bool $foldersToCheck->each(fn ($folder) => deleteEmptyBackupFolder($folder, $server)); } +function streamBackupFromServer(Server $server, string $filename, string $contentType): StreamedResponse +{ + $disk = Storage::build([ + 'driver' => 'sftp', + 'host' => $server->ip, + 'port' => (int) $server->port, + 'username' => $server->user, + 'privateKey' => $server->privateKey->getKeyLocation(), + 'root' => '/', + ]); + + if (! $disk->exists($filename)) { + throw new FileNotFoundException($filename); + } + + return new StreamedResponse(function () use ($disk, $filename) { + if (ob_get_level()) { + ob_end_clean(); + } + $stream = $disk->readStream($filename); + if ($stream === false || is_null($stream)) { + abort(500, 'Failed to open stream for the requested file.'); + } + while (! feof($stream)) { + echo fread($stream, 2048); + flush(); + } + + fclose($stream); + }, 200, [ + 'Content-Type' => $contentType, + 'Content-Disposition' => 'attachment; filename="'.basename($filename).'"', + ]); +} + function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void { if (empty($filenames) || ! $s3) { @@ -430,8 +469,12 @@ function deleteOldBackupsFromS3($backup): Collection ->all(); if (! empty($filesToDelete)) { - deleteBackupsS3($filesToDelete, $backup->s3); - $processedBackups = $backupsToDelete; + try { + deleteBackupsS3($filesToDelete, $backup->s3); + $processedBackups = $backupsToDelete; + } catch (Throwable $e) { + report($e); + } } return $processedBackups; diff --git a/resources/views/livewire/project/shared/storages/volume-backups.blade.php b/resources/views/livewire/project/shared/storages/volume-backups.blade.php index e86c3a585..cc354e88d 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups.blade.php @@ -10,12 +10,12 @@ @else @include('livewire.project.shared.storages.volume-backups.general') @endif - -@script - -@endscript + @script + + @endscript + diff --git a/resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php index 575eca1f5..6ee971b1d 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php @@ -1,7 +1,8 @@

Retention

- Save + Save
@@ -16,15 +17,15 @@

Local Backup Retention

@@ -34,13 +35,15 @@

S3 Storage Retention

diff --git a/resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php index be3120ee0..2d5e328e4 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php @@ -10,13 +10,14 @@

S3

- Save + Save @if (!$saveToS3) Enable S3 + wire:target="toggleS3" isHighlighted canGate="update" :canResource="$backup">Enable S3 @else Disable S3 + wire:target="toggleS3" canGate="update" :canResource="$backup">Disable S3 @endif
@@ -29,7 +30,8 @@ @endif
- + @foreach ($availableS3Storages as $s3Storage) @endforeach @@ -39,10 +41,12 @@
@if ($saveToS3) + helper="When enabled, backup files are deleted locally after a successful S3 upload." canGate="update" + :canResource="$backup" /> @else + helper="When enabled, backup files are deleted locally after a successful S3 upload." disabled + canGate="update" :canResource="$backup" /> @endif
diff --git a/routes/web.php b/routes/web.php index ddcb36364..c26b3eb15 100644 --- a/routes/web.php +++ b/routes/web.php @@ -98,8 +98,7 @@ use App\Models\ServiceDatabase; use App\Providers\RouteServiceProvider; use Illuminate\Support\Facades\Route; -use Illuminate\Support\Facades\Storage; -use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; Route::post('/forgot-password', [Controller::class, 'forgot_password'])->name('password.forgot')->middleware('throttle:forgot-password'); Route::get('/realtime', [Controller::class, 'realtime_test'])->middleware('auth'); @@ -389,41 +388,13 @@ $server = $execution->scheduledDatabaseBackup->database->destination->server; } - $privateKeyLocation = $server->privateKey->getKeyLocation(); - $disk = Storage::build([ - 'driver' => 'sftp', - 'host' => $server->ip, - 'port' => (int) $server->port, - 'username' => $server->user, - 'privateKey' => $privateKeyLocation, - 'root' => '/', - ]); - if (! $disk->exists($filename)) { - if ($execution->scheduledDatabaseBackup->disable_local_backup === true && $execution->scheduledDatabaseBackup->save_s3 === true) { - return response()->json(['message' => 'Backup not available locally, but available on S3.'], 404); - } - - return response()->json(['message' => 'Backup not found locally on the server.'], 404); + return streamBackupFromServer($server, $filename, 'application/octet-stream'); + } catch (FileNotFoundException) { + if (isset($execution) && $execution->scheduledDatabaseBackup->disable_local_backup === true && $execution->scheduledDatabaseBackup->save_s3 === true) { + return response()->json(['message' => 'Backup not available locally, but available on S3.'], 404); } - return new StreamedResponse(function () use ($disk, $filename) { - if (ob_get_level()) { - ob_end_clean(); - } - $stream = $disk->readStream($filename); - if ($stream === false || is_null($stream)) { - abort(500, 'Failed to open stream for the requested file.'); - } - while (! feof($stream)) { - echo fread($stream, 2048); - flush(); - } - - fclose($stream); - }, 200, [ - 'Content-Type' => 'application/octet-stream', - 'Content-Disposition' => 'attachment; filename="'.basename($filename).'"', - ]); + return response()->json(['message' => 'Backup not found locally on the server.'], 404); } catch (Throwable $e) { return response()->json(['message' => 'Failed to download backup.'], 500); } @@ -455,37 +426,9 @@ return response()->json(['message' => 'Server not found.'], 404); } - $filename = $execution->filename; - $disk = Storage::build([ - 'driver' => 'sftp', - 'host' => $server->ip, - 'port' => (int) $server->port, - 'username' => $server->user, - 'privateKey' => $server->privateKey->getKeyLocation(), - 'root' => '/', - ]); - if (! $disk->exists($filename)) { - return response()->json(['message' => 'Backup not found locally on the server.'], 404); - } - - return new StreamedResponse(function () use ($disk, $filename) { - if (ob_get_level()) { - ob_end_clean(); - } - $stream = $disk->readStream($filename); - if ($stream === false || is_null($stream)) { - abort(500, 'Failed to open stream for the requested file.'); - } - while (! feof($stream)) { - echo fread($stream, 2048); - flush(); - } - - fclose($stream); - }, 200, [ - 'Content-Type' => 'application/gzip', - 'Content-Disposition' => 'attachment; filename="'.basename($filename).'"', - ]); + return streamBackupFromServer($server, $execution->filename, 'application/gzip'); + } catch (FileNotFoundException) { + return response()->json(['message' => 'Backup not found locally on the server.'], 404); } catch (Throwable) { return response()->json(['message' => 'Failed to download backup.'], 500); } diff --git a/tests/Feature/BackupRetentionAndStaleDetectionTest.php b/tests/Feature/BackupRetentionAndStaleDetectionTest.php index cd4a76feb..97e45599f 100644 --- a/tests/Feature/BackupRetentionAndStaleDetectionTest.php +++ b/tests/Feature/BackupRetentionAndStaleDetectionTest.php @@ -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 @@ 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'); diff --git a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php index 962628298..f2890114c 100644 --- a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php +++ b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php @@ -28,3 +28,35 @@ ['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', + [ + '/]*type="submit")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Save<\/x-forms\.button>/s', + '/]*id="retentionAmountLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s', + '/]*id="retentionDaysLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s', + '/]*id="retentionMaxStorageLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s', + '/]*id="retentionAmountS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s', + '/]*id="retentionDaysS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s', + '/]*id="retentionMaxStorageS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s', + ], + ], + 'S3 controls' => [ + 'resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php', + [ + '/]*type="submit")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Save<\/x-forms\.button>/s', + '/]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Enable S3<\/x-forms\.button>/s', + '/]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Disable S3<\/x-forms\.button>/s', + '/]*id="s3StorageId")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>/s', + '/]*id="disableLocalBackup")(?=[^>]*instantSave)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s', + '/]*id="disableLocalBackup")(?=[^>]*disabled)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s', + ], + ], +]); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 256dbb5e5..9b650b942 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -19,11 +19,15 @@ 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 @@ ->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, '')); +}); + 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 @@ ->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 @@ ->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 @@ ->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(); diff --git a/tests/Unit/BackupDownloadHelperTest.php b/tests/Unit/BackupDownloadHelperTest.php new file mode 100644 index 000000000..8ed499df6 --- /dev/null +++ b/tests/Unit/BackupDownloadHelperTest.php @@ -0,0 +1,72 @@ +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'); +});