Ctrl K
ComunidadGuías…

tests/Feature/DatabaseBackupShellQuotingTest.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?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(' ');
});