diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 68be46444..6839bb771 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -68,6 +68,28 @@ public function __construct( parent::__construct($this->localMachineHelper, $this->datastoreCloud, $this->datastoreAcli, $this->cloudCredentials, $this->telemetryHelper, $this->projectDir, $this->cloudApiClientService, $this->sshHelper, $this->sshDir, $logger, $this->selfUpdateManager); } + /** + * Get the backup download URL. + * This is primarily used for testing purposes. + */ + public function getBackupDownloadUrl(): ?UriInterface + { + return $this->backupDownloadUrl ?? null; + } + + /** + * Set the backup download URL. + * This is primarily used for testing purposes. + */ + public function setBackupDownloadUrl(string|UriInterface $url): void + { + if (is_string($url)) { + $this->backupDownloadUrl = new \GuzzleHttp\Psr7\Uri($url); + } else { + $this->backupDownloadUrl = $url; + } + } + /** * @see https://github.com/drush-ops/drush/blob/c21a5a24a295cc0513bfdecead6f87f1a2cf91a2/src/Sql/SqlMysql.php#L168 * @return string[] @@ -96,20 +118,24 @@ private function listTablesQuoted(string $out): array public static function getBackupPath(object $environment, DatabaseResponse $database, object $backupResponse): string { - // Databases have a machine name not exposed via the API; we can only - // approximately reconstruct it and match the filename you'd get downloading - // a backup from Cloud UI. if ($database->flags->default) { $dbMachineName = $database->name . $environment->name; } else { $dbMachineName = 'db' . $database->id; } - $filename = implode('-', [ - $environment->name, - $database->name, - $dbMachineName, - $backupResponse->completedAt, - ]) . '.sql.gz'; + if (PHP_OS_FAMILY === 'Windows') { + // Use short filename to comply with 8.3 format and avoid long path issues. + $hash = substr(md5($environment->name . $database->name . $dbMachineName . $backupResponse->completedAt), 0, 8); + $filename = $hash . '.sql.gz'; + } else { + $completedAtFormatted = $backupResponse->completedAt; + $filename = implode('-', [ + $environment->name, + $database->name, + $dbMachineName, + $completedAtFormatted, + ]) . '.sql.gz'; + } return Path::join(sys_get_temp_dir(), $filename); } @@ -261,12 +287,13 @@ static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $ou if ($codebaseUuid) { // Download the backup file directly from the provided URL. $downloadUrl = $backupResponse->links->download->href; - $this->httpClient->request('GET', $downloadUrl, [ + $response = $this->httpClient->request('GET', $downloadUrl, [ 'progress' => static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $output): void { self::displayDownloadProgress($totalBytes, $downloadedBytes, $progress, $output); }, 'sink' => $localFilepath, ]); + $this->validateDownloadResponse($response, $localFilepath); return $localFilepath; } $acquiaCloudClient->stream( @@ -274,6 +301,7 @@ static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $ou "/environments/$environment->uuid/databases/$database->name/backups/$backupResponse->id/actions/download", $acquiaCloudClient->getOptions() ); + $this->validateDownloadedFile($localFilepath); return $localFilepath; } catch (RequestException $exception) { // Deal with broken SSL certificates. @@ -308,14 +336,100 @@ static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $ou throw new AcquiaCliException('Could not download backup'); } - public function setBackupDownloadUrl(UriInterface $url): void + /** + * Validates the HTTP response from a database backup download request. + * + * @param \Psr\Http\Message\ResponseInterface $response The HTTP response object + * @param string $localFilepath The local file path where the backup was downloaded + * @throws \Acquia\Cli\Exception\AcquiaCliException If the response is invalid + */ + private function validateDownloadResponse(object $response, string $localFilepath): void + { + $statusCode = $response->getStatusCode(); + + // Check for successful HTTP response. + if ($statusCode !== 200) { + // Clean up the potentially corrupted file. + if (file_exists($localFilepath)) { + $this->localMachineHelper->getFilesystem()->remove($localFilepath); + } + throw new AcquiaCliException( + 'Database backup download failed with HTTP status {status}. Please try again or contact support.', + ['status' => $statusCode] + ); + } + + // Validate the downloaded file. + $this->validateDownloadedFile($localFilepath); + } + + /** + * Validates that the downloaded backup file exists and is not empty. + * + * @param string $localFilepath The local file path to validate + * @throws \Acquia\Cli\Exception\AcquiaCliException If the file is invalid + */ + private function validateDownloadedFile(string $localFilepath): void { - $this->backupDownloadUrl = $url; + // Check if file exists. + if (!file_exists($localFilepath)) { + throw new AcquiaCliException( + 'Database backup download failed: file was not created. Please try again or contact support.' + ); + } + + // Check if file is not empty. + $fileSize = filesize($localFilepath); + if ($fileSize === 0 || $fileSize === false) { + // Clean up the empty/invalid file. + $this->localMachineHelper->getFilesystem()->remove($localFilepath); + throw new AcquiaCliException( + 'Database backup download failed or returned an invalid response. Please try again or contact support.' + ); + } + + // Optional: Validate gzip file header (backup files are .sql.gz) + if (str_ends_with($localFilepath, '.gz')) { + $this->validateGzipFile($localFilepath); + } } - private function getBackupDownloadUrl(): ?UriInterface + /** + * Validates that the downloaded file is a valid gzip file. + * + * @param string $localFilepath The local file path to validate + * @throws \Acquia\Cli\Exception\AcquiaCliException If the file is not a valid gzip file + */ + private function validateGzipFile(string $localFilepath): void { - return $this->backupDownloadUrl ?? null; + // Read the first 2 bytes to check for gzip magic number (0x1f 0x8b) + $handle = fopen($localFilepath, 'rb'); + if ($handle === false) { + throw new AcquiaCliException( + 'Database backup download failed: unable to read downloaded file. Please try again or contact support.' + ); + } + + $header = fread($handle, 2); + fclose($handle); + + if ($header === false || strlen($header) !== 2) { + $this->localMachineHelper->getFilesystem()->remove($localFilepath); + throw new AcquiaCliException( + 'Database backup download failed: file is too small to be valid. Please try again or contact support.' + ); + } + + // Check for gzip magic number. + $byte1 = ord($header[0]); + $byte2 = ord($header[1]); + + if ($byte1 !== 0x1f || $byte2 !== 0x8b) { + $this->localMachineHelper->getFilesystem()->remove($localFilepath); + throw new AcquiaCliException( + 'Database backup download failed or returned an invalid response. The downloaded file is not a valid gzip archive. Please try again or contact support.' + ); + } } public static function displayDownloadProgress(mixed $totalBytes, mixed $downloadedBytes, mixed &$progress, OutputInterface $output): void diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 89739d341..22595011a 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -311,13 +311,6 @@ protected function mockExecuteMySqlImport( $process = $this->mockProcess($success); $filePath = Path::join(sys_get_temp_dir(), "$env-$dbName-$dbMachineName-$createdAt.sql.gz"); $command = $pvExists ? 'pv "${:LOCAL_DUMP_FILEPATH}" --bytes --rate | gunzip | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"' : 'gunzip -c "${:LOCAL_DUMP_FILEPATH}" | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"'; - $expectedEnv = [ - 'LOCAL_DUMP_FILEPATH' => $filePath, - 'MYSQL_DATABASE' => $localDbName, - 'MYSQL_HOST' => 'localhost', - 'MYSQL_PASSWORD' => 'drupal', - 'MYSQL_USER' => 'drupal', - ]; // MySQL import command. $localMachineHelper ->executeFromCmd( @@ -328,14 +321,33 @@ protected function mockExecuteMySqlImport( return $printOutput === false; }), null, - Argument::that(function ($env) use ($expectedEnv) { - if (!is_array($env)) { + Argument::that(function ($envVars) use ($localDbName) { + // On Windows, the filepath is in 8.3 format (hashed), + // so we can't do strict matching. We just verify that + // the required environment variables exist with expected values. + if (!is_array($envVars)) { return false; } - foreach ($expectedEnv as $k => $v) { - if (!array_key_exists($k, $env) || $env[$k] !== $v) { - return false; - } + // Check required env vars exist (values vary by platform for LOCAL_DUMP_FILEPATH) + if (!array_key_exists('LOCAL_DUMP_FILEPATH', $envVars)) { + return false; + } + // Verify the filepath ends with expected suffix and is a valid gzip file. + if (!str_ends_with($envVars['LOCAL_DUMP_FILEPATH'], '.sql.gz')) { + return false; + } + // Verify other required env vars. + if (!array_key_exists('MYSQL_DATABASE', $envVars) || $envVars['MYSQL_DATABASE'] !== $localDbName) { + return false; + } + if (!array_key_exists('MYSQL_HOST', $envVars) || $envVars['MYSQL_HOST'] !== 'localhost') { + return false; + } + if (!array_key_exists('MYSQL_PASSWORD', $envVars) || $envVars['MYSQL_PASSWORD'] !== 'drupal') { + return false; + } + if (!array_key_exists('MYSQL_USER', $envVars) || $envVars['MYSQL_USER'] !== 'drupal') { + return false; } return true; }) @@ -387,7 +399,7 @@ public function mockGetBackup(mixed $environment): void $this->mockDownloadBackup($databases[0], $environment, $backups[0]); } - protected function mockDownloadBackup(object $database, object $environment, object $backup, int $curlCode = 0): object + protected function mockDownloadBackup(object $database, object $environment, object $backup, int $curlCode = 0, string $validationError = ''): object { if ($curlCode) { $this->prophet->prophesize(StreamInterface::class); @@ -414,13 +426,49 @@ protected function mockDownloadBackup(object $database, object $environment, obj } else { $dbMachineName = 'db' . $database->id; } - $filename = implode('-', [ - $environment->name, - $database->name, - $dbMachineName, - $backup->completedAt, - ]) . '.sql.gz'; + if (PHP_OS_FAMILY === 'Windows') { + // Use short filename to comply with 8.3 format and avoid long path issues. + $hash = substr(md5($environment->name . $database->name . $dbMachineName . $backup->completedAt), 0, 8); + $filename = $hash . '.sql.gz'; + } else { + $completedAtFormatted = $backup->completedAt; + $filename = implode('-', [ + $environment->name, + $database->name, + $dbMachineName, + $completedAtFormatted, + ]) . '.sql.gz'; + } $localFilepath = Path::join(sys_get_temp_dir(), $filename); + + // Create file based on validation error type. + switch ($validationError) { + case 'empty': + // Create an empty file to test empty file validation. + file_put_contents($localFilepath, ''); + break; + case 'invalid_gzip': + // Create a non-gzip file to test gzip validation. + file_put_contents($localFilepath, 'This is plain text, not gzipped content'); + break; + case 'missing': + // Don't create a file to test missing file validation. + if (file_exists($localFilepath)) { + unlink($localFilepath); + } + break; + case 'too_small': + // Create a file with only 1 byte to test file too small validation. + file_put_contents($localFilepath, 'X'); + break; + default: + // Create a valid gzip file for normal testing. + $content = 'Mock SQL dump content for testing'; + $gzippedContent = gzencode($content); + file_put_contents($localFilepath, $gzippedContent); + break; + } + $this->clientProphecy->addOption('sink', $localFilepath) ->shouldBeCalled(); $this->clientProphecy->addOption('curl.options', [ @@ -435,14 +483,32 @@ protected function mockDownloadBackup(object $database, object $environment, obj return $database; } - protected function mockDownloadCodebaseBackup(object $database, string $url, object $backup, int $curlCode = 0): object + + protected function mockDownloadCodebaseBackup(object $database, string $url, object $backup, int $curlCode = 0, string $validationError = ''): object { - $filename = implode('-', [ - 'environment_3e8ecbec-ea7c-4260-8414-ef2938c859bc', - $database->name ?? 'example', - 'dbexample', - '2025-04-01T13:01:06.603Z', - ]) . '.sql.gz'; + // Calculate dbMachineName the same way as getBackupPath. + $environment = (object) ['name' => 'environment_3e8ecbec-ea7c-4260-8414-ef2938c859bc']; + if (isset($database->flags->default) && $database->flags->default) { + $dbMachineName = $database->name . $environment->name; + } else { + $dbMachineName = 'db' . ($database->id ?? 'example'); + } + + $completedAtFormatted = $backup->completedAt ?? '2025-04-01T13:01:06.603Z'; + + // Use the same filename generation logic as getBackupPath() to ensure consistency. + // On Windows, use short filename to comply with 8.3 format and avoid long path issues. + if (PHP_OS_FAMILY === 'Windows') { + $hash = substr(md5($environment->name . ($database->name ?? 'example') . $dbMachineName . $completedAtFormatted), 0, 8); + $filename = $hash . '.sql.gz'; + } else { + $filename = implode('-', [ + $environment->name, + $database->name ?? 'example', + $dbMachineName, + $completedAtFormatted, + ]) . '.sql.gz'; + } $localFilepath = Path::join(sys_get_temp_dir(), $filename); // Cloud API client options are always set first. @@ -461,7 +527,8 @@ protected function mockDownloadCodebaseBackup(object $database, string $url, obj // Mock the HTTP client request for codebase downloads. $downloadUrl = $backup->links->download->href ?? 'https://example.com/download-backup'; - $response = $this->prophet->prophesize(ResponseInterface::class); + $statusCode = $validationError === 'http_error' ? 500 : 200; + $response = new \GuzzleHttp\Psr7\Response($statusCode); $capturedOpts = null; $this->httpClientProphecy @@ -484,13 +551,35 @@ protected function mockDownloadCodebaseBackup(object $database, string $url, obj return true; }) ) - ->will(function () use (&$capturedOpts, $response): ResponseInterface { + ->will(function () use (&$capturedOpts, $response, $localFilepath, $validationError): \Psr\Http\Message\ResponseInterface { + // Create file based on validation error type. + switch ($validationError) { + case 'http_error': + // For HTTP error, create file that will be cleaned up. + $content = 'Mock SQL dump content for testing'; + $gzippedContent = gzencode($content); + if ($gzippedContent !== false) { + file_put_contents($localFilepath, $gzippedContent); + } + break; + default: + // Create a valid gzip file for validation. + $content = 'Mock SQL dump content for testing'; + $gzippedContent = gzencode($content); + if ($gzippedContent !== false) { + file_put_contents($localFilepath, $gzippedContent); + } + break; + } + // Simulate the download to force progress rendering. - $progress = $capturedOpts['progress']; - $progress(100, 0); - $progress(100, 50); - $progress(100, 100); - return $response->reveal(); + if (isset($capturedOpts['progress'])) { + $progress = $capturedOpts['progress']; + $progress(100, 0); + $progress(100, 50); + $progress(100, 100); + } + return $response; }) ->shouldBeCalled(); diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 9d79eebec..56fd2a359 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -13,6 +13,7 @@ use AcquiaCloudApi\Response\SiteInstanceDatabaseBackupResponse; use AcquiaCloudApi\Response\SiteInstanceDatabaseResponse; use GuzzleHttp\Client; +use GuzzleHttp\Psr7\Uri; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Console\Output\BufferedOutput; @@ -321,7 +322,151 @@ public function testPullDatabaseWithInvalidSslCertificate(int $errorCode): void $this->assertStringContainsString('Trying alternative host other.example.com', $output); } - protected function setupPullDatabase(bool $mysqlConnectSuccessful, bool $mockIdeFs = false, bool $onDemand = false, bool $mockGetAcsfSites = true, bool $multiDb = false, int $curlCode = 0, bool $existingBackups = true, bool $onDemandSuccess = true): void + /** + * Test that the getBackupDownloadUrl and setBackupDownloadUrl methods work correctly. + */ + public function testBackupDownloadUrlGetterSetter(): void + { + // Test initial state (null). + $this->assertNull($this->command->getBackupDownloadUrl()); + + // Test setting with string. + $backupUrl = 'https://www.example.com/download-backup'; + $this->command->setBackupDownloadUrl($backupUrl); + $this->assertNotNull($this->command->getBackupDownloadUrl()); + $this->assertEquals($backupUrl, (string) $this->command->getBackupDownloadUrl()); + + // Test setting with UriInterface. + $uri = new Uri('https://other.example.com/download-backup'); + $this->command->setBackupDownloadUrl($uri); + $this->assertEquals((string) $uri, (string) $this->command->getBackupDownloadUrl()); + } + + /** + * Test that downloading a backup with an empty file fails with validation error. + */ + public function testPullDatabaseWithEmptyFile(): void + { + $this->setupPullDatabase(true, false, false, true, false, 0, true, true, 'empty'); + $inputs = self::inputChooseEnvironment(); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Database backup download failed or returned an invalid response'); + $this->executeCommand(['--no-scripts' => true], $inputs); + } + + /** + * Test that downloading a backup with invalid gzip content fails with validation error. + */ + public function testPullDatabaseWithInvalidGzip(): void + { + $this->setupPullDatabase(true, false, false, true, false, 0, true, true, 'invalid_gzip'); + $inputs = self::inputChooseEnvironment(); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('The downloaded file is not a valid gzip archive'); + $this->executeCommand(['--no-scripts' => true], $inputs); + } + + /** + * Test that downloading a backup when file is missing fails with validation error. + */ + public function testPullDatabaseWithMissingFile(): void + { + $this->setupPullDatabase(true, false, false, true, false, 0, true, true, 'missing'); + $inputs = self::inputChooseEnvironment(); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Database backup download failed: file was not created'); + $this->executeCommand(['--no-scripts' => true], $inputs); + } + + /** + * Test that downloading a backup with file too small to be valid gzip fails. + */ + public function testPullDatabaseWithFileTooSmall(): void + { + $this->setupPullDatabase(true, false, false, true, false, 0, true, true, 'too_small'); + $inputs = self::inputChooseEnvironment(); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Database backup download failed: file is too small to be valid'); + $this->executeCommand(['--no-scripts' => true], $inputs); + } + + /** + * Test that downloading a backup with HTTP error status fails with validation error (codebase path). + */ + public function testPullDatabasesWithCodebaseUuidHttpError(): void + { + $codebaseUuid = '11111111-041c-44c7-a486-7972ed2cafc8'; + self::SetEnvVars(['AH_CODEBASE_UUID' => $codebaseUuid]); + + // Mock the codebase returned from /codebases/{uuid}. + $codebase = $this->getMockCodeBaseResponse(); + $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid) + ->willReturn($codebase); + + // Build one codebase environment (so prompt is skipped). + $codebaseEnv = $this->getMockCodeBaseEnvironment(); + $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid . '/environments') + ->willReturn([$codebaseEnv]) + ->shouldBeCalled(); + + $codebaseSites = $this->getMockCodeBaseSites(); + $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid . '/sites') + ->willReturn($codebaseSites); + $siteInstance = $this->getMockSiteInstanceResponse(); + + $this->clientProphecy->request('get', '/site-instances/8979a8ac-80dc-4df8-b2f0-6be36554a370.3e8ecbec-ea7c-4260-8414-ef2938c859bc') + ->willReturn($siteInstance) + ->shouldBeCalled(); + $siteId = '8979a8ac-80dc-4df8-b2f0-6be36554a370'; + $site = $this->getMockSite(); + $this->clientProphecy->request('get', '/sites/' . $siteId) + ->willReturn($site) + ->shouldBeCalled(); + $siteInstanceDatabase = $this->getMockSiteInstanceDatabaseResponse(); + $this->clientProphecy->request('get', '/site-instances/8979a8ac-80dc-4df8-b2f0-6be36554a370.3e8ecbec-ea7c-4260-8414-ef2938c859bc/database') + ->willReturn($siteInstanceDatabase) + ->shouldBeCalled(); + $createSiteInstanceDatabaseBackup = $this->getMockSiteInstanceDatabaseBackupsResponse('post', '201'); + $this->clientProphecy->request('post', '/site-instances/8979a8ac-80dc-4df8-b2f0-6be36554a370.3e8ecbec-ea7c-4260-8414-ef2938c859bc/database/backups') + ->willReturn($createSiteInstanceDatabaseBackup); + $siteInstanceDatabaseBackups = $this->getMockSiteInstanceDatabaseBackupsResponse(); + $this->clientProphecy->request('get', '/site-instances/8979a8ac-80dc-4df8-b2f0-6be36554a370.3e8ecbec-ea7c-4260-8414-ef2938c859bc/database/backups') + ->willReturn($siteInstanceDatabaseBackups->_embedded->items) + ->shouldBeCalled(); + + $url = "https://environment-service-php.acquia.com/api/environments/d3f7270e-c45f-4801-9308-5e8afe84a323/"; + $this->mockDownloadCodebaseBackup( + EnvironmentTransformer::transformSiteInstanceDatabase(new SiteInstanceDatabaseResponse($siteInstanceDatabase)), + $url, + EnvironmentTransformer::transformSiteInstanceDatabaseBackup(new SiteInstanceDatabaseBackupResponse($siteInstanceDatabaseBackups->_embedded->items[0])), + 0, + 'http_error' + ); + + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockExecuteMySqlConnect($localMachineHelper, true); + $fs = $this->prophet->prophesize(Filesystem::class); + $localMachineHelper->getFilesystem()->willReturn($fs)->shouldBeCalled(); + $fs->remove(Argument::type('string'))->shouldBeCalled(); + $inputs = self::inputChooseEnvironment(); + + try { + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Database backup download failed with HTTP status 500'); + $this->executeCommand([ + '--no-scripts' => true, + '--on-demand' => false, + ], $inputs); + } finally { + self::unsetEnvVars(['AH_CODEBASE_UUID']); + } + } + + protected function setupPullDatabase(bool $mysqlConnectSuccessful, bool $mockIdeFs = false, bool $onDemand = false, bool $mockGetAcsfSites = true, bool $multiDb = false, int $curlCode = 0, bool $existingBackups = true, bool $onDemandSuccess = true, string $validationError = ''): void { $applicationsResponse = $this->mockApplicationsRequest(); $this->mockApplicationRequest(); @@ -336,7 +481,7 @@ protected function setupPullDatabase(bool $mysqlConnectSuccessful, bool $mockIde if ($multiDb) { $databaseResponse2 = $databasesResponse[array_search('profserv2', array_column($databasesResponse, 'name'), true)]; $databaseBackupsResponse2 = $this->mockDatabaseBackupsResponse($selectedEnvironment, $databaseResponse2->name, 1, $existingBackups); - $this->mockDownloadBackup($databaseResponse2, $selectedEnvironment, $databaseBackupsResponse2->_embedded->items[0], $curlCode); + $this->mockDownloadBackup($databaseResponse2, $selectedEnvironment, $databaseBackupsResponse2->_embedded->items[0], $curlCode, $validationError); } $sshHelper = $this->mockSshHelper(); @@ -358,7 +503,18 @@ protected function setupPullDatabase(bool $mysqlConnectSuccessful, bool $mockIde } $databaseBackupsResponse = $this->mockDatabaseBackupsResponse($selectedEnvironment, $databaseResponse->name, 1, $existingBackups); - $this->mockDownloadBackup($databaseResponse, $selectedEnvironment, $databaseBackupsResponse->_embedded->items[0], $curlCode); + $this->mockDownloadBackup($databaseResponse, $selectedEnvironment, $databaseBackupsResponse->_embedded->items[0], $curlCode, $validationError); + + // If there's a validation error, we don't need to mock the rest of the database operations. + if ($validationError) { + // Only mock filesystem for errors that need it (not 'missing' which throws before any cleanup). + if ($validationError !== 'missing') { + $fs = $this->prophet->prophesize(Filesystem::class); + $localMachineHelper->getFilesystem()->willReturn($fs)->shouldBeCalled(); + $fs->remove(Argument::type('string'))->shouldBeCalled(); + } + return; + } $fs = $this->prophet->prophesize(Filesystem::class); // Set up file system.