mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
- Remove tests that only check markup, CSS, static text, wire:key strings, framework behavior, copied app logic, or expect(true) - Replace source-grep and Reflection checks of security fixes with tests that run the real code (shell quoting, locked properties, 403 checks, webhook fork trust, log redaction, Sentinel token cleanup) - Assert response bodies and unchanged data on API error paths - Run the Traefik version job through handle() instead of private methods - Update stale tests for scheduled task props, the default Postgres image, and the disabled transfer link of unvalidated servers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
8.5 KiB
PHP
191 lines
8.5 KiB
PHP
<?php
|
|
|
|
use App\Events\BackupCreated;
|
|
use App\Jobs\DatabaseBackupJob;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\S3Storage;
|
|
use App\Models\ScheduledDatabaseBackup;
|
|
use App\Models\ScheduledDatabaseBackupExecution;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\ServiceDatabase;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\Team;
|
|
use App\Support\RemoteProcessCommand;
|
|
use Illuminate\Filesystem\FilesystemAdapter;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
const BACKUP_QUOTING_PAYLOAD = '../app\' $HOME \'db x';
|
|
|
|
beforeEach(function () {
|
|
Server::flushIdentityMap();
|
|
Storage::fake('ssh-keys');
|
|
Storage::fake('ssh-mux');
|
|
Event::fake([BackupCreated::class]);
|
|
Notification::fake();
|
|
config(['constants.ssh.mux_enabled' => false]);
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
|
|
$this->team = Team::factory()->create(['name' => 'Acme']);
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
|
]);
|
|
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
|
|
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
|
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
|
$this->commands = collect();
|
|
Process::fake(function ($process) {
|
|
$script = remoteProcessScript($process);
|
|
$this->commands->push($script);
|
|
|
|
return Process::result(output: match (true) {
|
|
str_contains($script, 'du -b') => '128',
|
|
str_contains($script, '| grep -E') => "POSTGRES_USER=app\nPOSTGRES_DB=app",
|
|
default => '',
|
|
});
|
|
});
|
|
});
|
|
|
|
function backupQuotingSchedule(mixed $database, Team $team, array $overrides = []): ScheduledDatabaseBackup
|
|
{
|
|
$backup = new ScheduledDatabaseBackup;
|
|
$backup->forceFill([
|
|
'enabled' => true,
|
|
'save_s3' => false,
|
|
'frequency' => 'daily',
|
|
'database_id' => $database->id,
|
|
'database_type' => $database->getMorphClass(),
|
|
'team_id' => $team->id,
|
|
...$overrides,
|
|
])->save();
|
|
|
|
return $backup->fresh();
|
|
}
|
|
|
|
function backupQuotingService(Server $server, StandaloneDocker $destination, Environment $environment): Service
|
|
{
|
|
return Service::factory()->create([
|
|
'server_id' => $server->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
'environment_id' => $environment->id,
|
|
]);
|
|
}
|
|
|
|
it('quotes the container name, the database name, and the backup paths in the backup commands', function () {
|
|
$database = create_standalone_postgresql($this->environment->id, $this->destination, ['status' => 'running:healthy']);
|
|
$backup = backupQuotingSchedule($database, $this->team, ['databases_to_backup' => BACKUP_QUOTING_PAYLOAD]);
|
|
|
|
(new DatabaseBackupJob($backup))->handle();
|
|
|
|
$file = ScheduledDatabaseBackupExecution::query()->sole()->filename;
|
|
$dumpCommand = "docker exec '{$database->uuid}' pg_dump --format=custom --username 'postgres' "
|
|
.escapeshellarg(BACKUP_QUOTING_PAYLOAD)." > '{$file}'";
|
|
|
|
expect($this->commands->implode("\n"))
|
|
->toContain("\nmkdir -p '".dirname($file)."'\n")
|
|
->toContain("\nsh -c ".escapeshellarg($dumpCommand)."\n")
|
|
->toContain("\ndu -b '{$file}' | cut -f1")
|
|
->not->toContain(' '.BACKUP_QUOTING_PAYLOAD.' ')
|
|
->not->toContain("docker exec {$database->uuid} ");
|
|
});
|
|
|
|
it('quotes the backup file in the mount of the S3 upload container', function () {
|
|
$storage = S3Storage::create([
|
|
'name' => 'Destination',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://8.8.8.8',
|
|
'team_id' => $this->team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$disk = Mockery::mock(FilesystemAdapter::class);
|
|
$disk->shouldReceive('files')->zeroOrMoreTimes()->andReturn([]);
|
|
$sshDisk = Storage::fake('ssh-keys');
|
|
Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk);
|
|
Storage::shouldReceive('build')->zeroOrMoreTimes()->andReturn($disk);
|
|
$database = create_standalone_postgresql($this->environment->id, $this->destination, ['status' => 'running:healthy']);
|
|
$backup = backupQuotingSchedule($database, $this->team, [
|
|
'save_s3' => true,
|
|
's3_storage_id' => $storage->id,
|
|
'databases_to_backup' => 'app',
|
|
]);
|
|
$backup->syncS3Storages([$storage->id]);
|
|
|
|
(new DatabaseBackupJob($backup->fresh()))->handle();
|
|
|
|
$file = ScheduledDatabaseBackupExecution::query()->sole()->filename;
|
|
expect($this->commands->first(fn (string $command) => str_contains($command, 'docker run -d')))
|
|
->toContain(' -v '.escapeshellarg("{$file}:{$file}:ro").' ')
|
|
->not->toContain(" -v {$file}:");
|
|
});
|
|
|
|
it('backs up a service database with a supported container name', function () {
|
|
$service = backupQuotingService($this->server, $this->destination, $this->environment);
|
|
$database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db-name_service.1', 'image' => 'postgres:16', 'status' => 'running:healthy']);
|
|
|
|
(new DatabaseBackupJob(backupQuotingSchedule($database, $this->team)))->handle();
|
|
|
|
expect($this->commands->implode("\n"))->toContain("docker exec 'db-name_service.1-{$service->uuid}' env")
|
|
->and(ScheduledDatabaseBackupExecution::query()->sole()->status)->toBe('success');
|
|
});
|
|
|
|
it('refuses to back up a service database whose container name is not supported', function (string $name) {
|
|
$service = backupQuotingService($this->server, $this->destination, $this->environment);
|
|
$database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db', 'image' => 'postgres:16', 'status' => 'running:healthy']);
|
|
ServiceDatabase::query()->whereKey($database->id)->update(['name' => $name]);
|
|
|
|
expect(fn () => (new DatabaseBackupJob(backupQuotingSchedule($database, $this->team)))->handle())
|
|
->toThrow(Exception::class, 'Invalid database container name.');
|
|
expect($this->commands->filter(fn (string $command) => str_contains($command, 'docker exec')))->toBeEmpty();
|
|
})->with([
|
|
'space' => ['db name'],
|
|
'newline' => ["db\nname"],
|
|
'shell payload' => ["db'; touch /tmp/pwn; '"],
|
|
]);
|
|
|
|
it('quotes the container name in the restart command of a service database and a service application', function () {
|
|
Queue::fake();
|
|
$service = backupQuotingService($this->server, $this->destination, $this->environment);
|
|
$database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db', 'image' => 'postgres:16']);
|
|
$application = ServiceApplication::create(['service_id' => $service->id, 'name' => 'app']);
|
|
ServiceDatabase::query()->whereKey($database->id)->update(['name' => "db'; touch /tmp/pwn; '"]);
|
|
ServiceApplication::query()->whereKey($application->id)->update(['name' => 'app$(id)']);
|
|
|
|
$database->fresh()->restart();
|
|
$application->fresh()->restart();
|
|
|
|
$databaseContainer = escapeshellarg("db'; touch /tmp/pwn; '-{$service->uuid}");
|
|
expect(RemoteProcessCommand::read(Activity::query()->latest('id')->firstOrFail()))->toBe("docker restart {$databaseContainer}")
|
|
->and($this->commands->implode("\n"))->toContain("\ndocker restart 'app\$(id)-{$service->uuid}'")
|
|
->not->toContain('docker restart app$(id)');
|
|
});
|
|
|
|
it('replaces shell and path separators in the database part of the backup file name', function () {
|
|
$database = create_standalone_postgresql($this->environment->id, $this->destination, ['status' => 'running:healthy']);
|
|
$backup = backupQuotingSchedule($database, $this->team, ['databases_to_backup' => 'safe_db-1,../db name']);
|
|
|
|
(new DatabaseBackupJob($backup))->handle();
|
|
|
|
$files = ScheduledDatabaseBackupExecution::query()->orderBy('id')->pluck('filename')->map(fn (string $file) => basename($file));
|
|
expect($files[0])->toStartWith('pg-dump-safe_db-1-')
|
|
->and($files[1])->toStartWith('pg-dump-..-db-name-')
|
|
->and($files->implode(''))->not->toContain(' ');
|
|
});
|