Files
coolify/tests/Feature/BackupS3DownloadTest.php

288 lines
13 KiB
PHP

<?php
use App\Jobs\DeleteFileDownloadJob;
use App\Jobs\PrepareFileDownloadJob;
use App\Livewire\Project\Database\BackupExecutions;
use App\Livewire\Project\Service\BackupExecutions as ServiceBackupExecutions;
use App\Livewire\Project\Shared\Storages\VolumeBackups;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use App\Services\FileDownloadStorageService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
const BACKUP_S3_DOWNLOAD_FILE = '/data/coolify/backups/databases/orders/pg-dump-orders-1.dmp';
beforeEach(function () {
config(['app.maintenance.driver' => 'file', 'constants.ssh.mux_enabled' => false]);
Storage::fake('ssh-keys');
Server::flushIdentityMap();
Once::flush();
InstanceSettings::forceCreate(['id' => 0]);
Team::factory()->create(['id' => 0]);
$this->downloadStorage = S3Storage::create([
'name' => 'Downloads',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'downloads',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
'team_id' => 0,
]);
InstanceSettings::find(0)->update(['file_download_s3_storage_id' => $this->downloadStorage->id]);
Once::flush();
$this->team = Team::factory()->create();
$this->otherTeam = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]);
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]);
$this->database = StandalonePostgresql::create([
'name' => 'orders-db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db',
'image' => 'postgres:17', 'environment_id' => $environment->id,
'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(),
]);
$this->backup = ScheduledDatabaseBackup::create([
'team_id' => $this->team->id,
'frequency' => 'daily',
'database_id' => $this->database->id,
'database_type' => $this->database->getMorphClass(),
]);
$this->execution = ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $this->backup->id,
'status' => 'success',
'filename' => BACKUP_S3_DOWNLOAD_FILE,
]);
$this->service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'environment_id' => $environment->id,
]);
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'admin']);
$this->user->teams()->attach($this->otherTeam, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
Bus::fake([PrepareFileDownloadJob::class]);
});
function fakeBackupFileSize(int|string $size): void
{
Process::fake(['*' => Process::result(output: (string) $size)]);
}
it('uploads a database backup through S3 for an admin of its team after a team switch', function () {
fakeBackupFileSize(1024);
$component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]);
session(['currentTeam' => $this->otherTeam]);
$component->call('download_file', $this->execution->id)
->assertDispatched('info')
->assertNotDispatched('open-backup-download')
->assertCount('pendingDownloads', 1);
Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null
&& $job->path === BACKUP_S3_DOWNLOAD_FILE
&& $job->serverId === $this->server->id
&& $job->downloadName === 'pg-dump-orders-1.dmp'
&& $job->s3StorageId === $this->downloadStorage->id
&& $job->userId === $this->user->id);
});
it('streams through the download route when no download storage is set', function () {
InstanceSettings::find(0)->update(['file_download_s3_storage_id' => null]);
Once::flush();
Process::fake();
Livewire::test(BackupExecutions::class, ['backup' => $this->backup])
->call('download_file', $this->execution->id)
->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id));
Process::assertNothingRan();
Bus::assertNotDispatched(PrepareFileDownloadJob::class);
});
it('streams through the download route when the backup is larger than one S3 upload', function () {
fakeBackupFileSize(FileDownloadStorageService::MAX_SIZE + 1);
Livewire::test(BackupExecutions::class, ['backup' => $this->backup])
->call('download_file', $this->execution->id)
->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id));
Bus::assertNotDispatched(PrepareFileDownloadJob::class);
});
it('lets the download route answer when the backup file is not on the server', function () {
fakeBackupFileSize('');
Livewire::test(BackupExecutions::class, ['backup' => $this->backup])
->call('download_file', $this->execution->id)
->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id));
Bus::assertNotDispatched(PrepareFileDownloadJob::class);
});
it('refuses team members', function () {
Process::fake();
$this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']);
$this->user->unsetRelation('teams');
$component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]);
$component->call('download_file', $this->execution->id)
->assertNotDispatched('open-backup-download');
Process::assertNothingRan();
Bus::assertNotDispatched(PrepareFileDownloadJob::class);
});
it('does not download an execution of another backup', function () {
Process::fake();
$foreignBackup = ScheduledDatabaseBackup::create([
'team_id' => $this->otherTeam->id,
'frequency' => 'daily',
'database_id' => $this->database->id + 1000,
'database_type' => $this->database->getMorphClass(),
]);
$foreignExecution = ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $foreignBackup->id,
'status' => 'success',
'filename' => '/data/coolify/backups/databases/other/secret.dmp',
]);
Livewire::test(BackupExecutions::class, ['backup' => $this->backup])
->call('download_file', $foreignExecution->id)
->assertDispatched('error', 'Backup execution not found.')
->assertNotDispatched('open-backup-download');
Process::assertNothingRan();
Bus::assertNotDispatched(PrepareFileDownloadJob::class);
});
it('uploads a volume backup through S3 and refuses members', function () {
fakeBackupFileSize(2048);
$application = Application::factory()->create([
'environment_id' => $this->database->environment_id,
'destination_id' => $this->database->destination_id,
'destination_type' => $this->database->destination_type,
]);
$volume = LocalPersistentVolume::create([
'name' => 'app-data',
'mount_path' => '/data',
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$backup = $volume->scheduledBackups()->create(['team_id' => $this->team->id, 'frequency' => 'daily']);
$execution = ScheduledVolumeBackupExecution::create([
'scheduled_volume_backup_id' => $backup->id,
'status' => 'success',
'filename' => '/data/coolify/backups/volumes/app/archive.tar.gz',
]);
$component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]);
session(['currentTeam' => $this->otherTeam]);
$component->call('downloadBackup', $execution->id)->assertCount('pendingDownloads', 1);
Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null
&& $job->path === '/data/coolify/backups/volumes/app/archive.tar.gz'
&& $job->serverId === $this->server->id);
$this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']);
$this->user->unsetRelation('teams');
$component->call('downloadBackup', $execution->id)->assertForbidden();
Bus::assertDispatchedTimes(PrepareFileDownloadJob::class, 1);
});
it('uploads a service database backup through S3', function () {
fakeBackupFileSize(1024);
$serviceDatabase = ServiceDatabase::create([
'service_id' => $this->service->id,
'name' => 'service-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->team->id,
'frequency' => 'daily',
'database_id' => $serviceDatabase->id,
'database_type' => $serviceDatabase->getMorphClass(),
]);
$execution = ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'filename' => '/data/coolify/backups/service/db.dmp',
]);
Livewire::test(ServiceBackupExecutions::class, ['service' => $this->service])
->call('downloadExecution', $execution->uuid)
->assertCount('pendingDownloads', 1);
Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null
&& $job->path === '/data/coolify/backups/service/db.dmp');
});
it('opens a finished link only for the user who asked for it', function () {
fakeBackupFileSize(1024);
$component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup])
->call('download_file', $this->execution->id);
$downloadId = $component->get('pendingDownloads')[0];
Cache::put(PrepareFileDownloadJob::cacheKey($downloadId), ['user_id' => $this->user->id + 1, 'status' => 'ready', 'url' => 'https://s3.example.com/other']);
$component->call('checkDownloads')->assertNotDispatched('open-backup-download')->assertCount('pendingDownloads', 1);
Cache::put(PrepareFileDownloadJob::cacheKey($downloadId), ['user_id' => $this->user->id, 'status' => 'ready', 'url' => 'https://s3.example.com/get']);
$component->call('checkDownloads')
->assertDispatched('open-backup-download', url: 'https://s3.example.com/get')
->assertCount('pendingDownloads', 0);
});
it('uploads the backup file from the server host with curl', function () {
Bus::fake([DeleteFileDownloadJob::class]);
Process::fake(['*' => Process::result(output: '')]);
$downloads = Mockery::mock(FileDownloadStorageService::class);
$downloads->shouldReceive('uploadUrl')->andReturn(['url' => 'https://s3.example.com/put?X-Amz-Signature=abc&x=1', 'headers' => ['Content-Disposition' => 'attachment']]);
$downloads->shouldReceive('downloadUrl')->andReturn('https://s3.example.com/get');
(new PrepareFileDownloadJob('dl-host', $this->user->id, $this->server->id, null, BACKUP_S3_DOWNLOAD_FILE, false, 'pg.dmp', $this->downloadStorage->id))->handle($downloads);
expect(Cache::get(PrepareFileDownloadJob::cacheKey('dl-host')))->toMatchArray(['status' => 'ready', 'url' => 'https://s3.example.com/get']);
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'curl -fsS')
&& str_contains(remoteProcessScript($process), BACKUP_S3_DOWNLOAD_FILE)
&& str_contains(remoteProcessScript($process), 'X-Amz-Signature=abc&x=1')
&& ! str_contains(remoteProcessScript($process), 'docker exec'));
});
it('builds host file commands that the non-root sudo parser keeps whole', function () {
$nonRoot = Server::factory()->make(['user' => 'deploy']);
$upload = FileDownloadStorageService::buildHostFileUploadCommand("/data/it's.dmp", 'https://s3.example.com/put?a=1&b=2', ['Content-Disposition' => 'attachment']);
$size = FileDownloadStorageService::buildHostFileSizeCommand("/data/it's.dmp");
expect(parseCommandsByLineForSudo(collect([$upload, $size]), $nonRoot))->toBe(['sudo '.$upload, 'sudo '.$size])
->and(fn () => FileDownloadStorageService::buildHostFileSizeCommand('/data/a;rm -rf /'))->toThrow(Exception::class);
});