From faaad8adfba6dcf7ce019a4a192b889150bba59c Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 28 Jan 2026 10:57:24 +0530 Subject: [PATCH 01/12] validating backup link before importing database --- src/Command/Pull/PullCommandBase.php | 114 +++++++++++++++++++++------ 1 file changed, 89 insertions(+), 25 deletions(-) diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 68be46444..50c548929 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -33,11 +33,9 @@ use Psr\Log\LoggerInterface; use React\EventLoop\Loop; use SelfUpdate\SelfUpdateManager; -use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\ConsoleOutput; -use Symfony\Component\Console\Output\ConsoleSectionOutput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Path; @@ -261,12 +259,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 +273,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,35 +308,99 @@ 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 { - $this->backupDownloadUrl = $url; + $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); } - private function getBackupDownloadUrl(): ?UriInterface + /** + * 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 { - return $this->backupDownloadUrl ?? null; + // 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); + } } - public static function displayDownloadProgress(mixed $totalBytes, mixed $downloadedBytes, mixed &$progress, OutputInterface $output): void + /** + * 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 { - if ($totalBytes > 0 && is_null($progress)) { - $progress = new ProgressBar($output, $totalBytes); - $progress->setFormat(' %current%/%max% [%bar%] %percent:3s%%'); - $progress->setProgressCharacter('💧'); - $progress->setOverwrite(true); - $progress->start(); - } - - if (!is_null($progress)) { - if ($totalBytes === $downloadedBytes && $progress->getProgressPercent() !== 1.0) { - $progress->finish(); - if ($output instanceof ConsoleSectionOutput) { - $output->clear(); - } - return; - } - $progress->setProgress($downloadedBytes); + // 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.' + ); } } From 37e199c23798f699c792f031bbd7a6534e817a69 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 28 Jan 2026 11:44:13 +0530 Subject: [PATCH 02/12] test failing fix --- src/Command/Pull/PullCommandBase.php | 44 +++++++++++++++++++ .../src/Commands/Pull/PullCommandTestBase.php | 14 +++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 50c548929..92fd10817 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -66,6 +66,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[] @@ -404,6 +426,28 @@ private function validateGzipFile(string $localFilepath): void } } + public static function displayDownloadProgress(mixed $totalBytes, mixed $downloadedBytes, mixed &$progress, OutputInterface $output): void + { + if ($totalBytes > 0 && is_null($progress)) { + $progress = new \Symfony\Component\Console\Helper\ProgressBar($output, $totalBytes); + $progress->setFormat(' %current%/%max% [%bar%] %percent:3s%%'); + $progress->setProgressCharacter('💧'); + $progress->setOverwrite(true); + $progress->start(); + } + + if (!is_null($progress)) { + if ($totalBytes === $downloadedBytes && $progress->getProgressPercent() !== 1.0) { + $progress->finish(); + if ($output instanceof \Symfony\Component\Console\Output\ConsoleSectionOutput) { + $output->clear(); + } + return; + } + $progress->setProgress($downloadedBytes); + } + } + /** * Create an on-demand backup and wait for it to become available. */ diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 89739d341..1c65c67cd 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -421,6 +421,12 @@ protected function mockDownloadBackup(object $database, object $environment, obj $backup->completedAt, ]) . '.sql.gz'; $localFilepath = Path::join(sys_get_temp_dir(), $filename); + + // Create a valid gzip file for validation. + $content = 'Mock SQL dump content for testing'; + $gzippedContent = gzencode($content); + file_put_contents($localFilepath, $gzippedContent); + $this->clientProphecy->addOption('sink', $localFilepath) ->shouldBeCalled(); $this->clientProphecy->addOption('curl.options', [ @@ -462,6 +468,7 @@ 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); + $response->getStatusCode()->willReturn(200); $capturedOpts = null; $this->httpClientProphecy @@ -484,7 +491,12 @@ protected function mockDownloadCodebaseBackup(object $database, string $url, obj return true; }) ) - ->will(function () use (&$capturedOpts, $response): ResponseInterface { + ->will(function () use (&$capturedOpts, $response, $localFilepath): ResponseInterface { + // Create a valid gzip file for validation. + $content = 'Mock SQL dump content for testing'; + $gzippedContent = gzencode($content); + file_put_contents($localFilepath, $gzippedContent); + // Simulate the download to force progress rendering. $progress = $capturedOpts['progress']; $progress(100, 0); From 0a58a1dc729d5adecc59a32cddc1026bfef29825 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 28 Jan 2026 11:53:18 +0530 Subject: [PATCH 03/12] code cov improvement --- .../src/Commands/Pull/PullCommandTestBase.php | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 1c65c67cd..e172f191a 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -387,6 +387,175 @@ public function mockGetBackup(mixed $environment): void $this->mockDownloadBackup($databases[0], $environment, $backups[0]); } + public function mockGetBackupWithHttpError(): void + { + $applications = $this->mockRequest('getApplications'); + $environments = $this->mockRequest('getApplicationEnvironments', $applications[0]->uuid); + $environment = $environments[0]; + $databases = $this->mockRequest('getEnvironmentsDatabases', $environment->id); + $database = $databases[0]; + $tamper = static function ($backups): void { + $backups[0]->completedAt = $backups[0]->completed_at; + }; + $backups = new BackupsResponse( + $this->mockRequest('getEnvironmentsDatabaseBackups', [ + $environment->id, + 'my_db', + ], null, null, $tamper) + ); + $backup = $backups[0]; + + $filename = implode('-', [ + $environment->name, + $database->name, + 'my_dbdev', + $backup->completedAt, + ]) . '.sql.gz'; + $localFilepath = Path::join(sys_get_temp_dir(), $filename); + + $this->mockDownloadBackupResponse($environment, $database->name, 1); + $this->clientProphecy->addOption('sink', $localFilepath)->shouldBeCalled(); + $this->clientProphecy->addOption('curl.options', [ + 'CURLOPT_FILE' => $localFilepath, + 'CURLOPT_RETURNTRANSFER' => false, + ])->shouldBeCalled(); + $this->clientProphecy->addOption('progress', Argument::type('Closure'))->shouldBeCalled(); + $this->clientProphecy->addOption('on_stats', Argument::type('Closure'))->shouldBeCalled(); + $this->clientProphecy->getOptions()->willReturn([]); + + // Set codebase UUID and mock codebase API calls. + $codebaseUuid = '11111111-041c-44c7-a486-7972ed2cafc8'; + putenv('AH_CODEBASE_UUID=' . $codebaseUuid); + + $codebase = $this->getMockCodeBaseResponse(); + $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid) + ->willReturn($codebase) + ->shouldBeCalled(); + + // Mock HTTP response with 404 error. + $response = $this->prophet->prophesize(ResponseInterface::class); + $response->getStatusCode()->willReturn(404); + $downloadUrl = $backup->links->download->href; + $this->httpClientProphecy->request('GET', $downloadUrl, Argument::type('array')) + ->willReturn($response->reveal()) + ->shouldBeCalled(); + } + + public function mockGetBackupWithInvalidGzip(): void + { + $applications = $this->mockRequest('getApplications'); + $environments = $this->mockRequest('getApplicationEnvironments', $applications[0]->uuid); + $environment = $environments[0]; + $databases = $this->mockRequest('getEnvironmentsDatabases', $environment->id); + $database = $databases[0]; + $tamper = static function ($backups): void { + $backups[0]->completedAt = $backups[0]->completed_at; + }; + $backups = new BackupsResponse( + $this->mockRequest('getEnvironmentsDatabaseBackups', [ + $environment->id, + 'my_db', + ], null, null, $tamper) + ); + $backup = $backups[0]; + + $filename = implode('-', [ + $environment->name, + $database->name, + 'my_dbdev', + $backup->completedAt, + ]) . '.sql.gz'; + $localFilepath = Path::join(sys_get_temp_dir(), $filename); + + $this->mockDownloadBackupResponse($environment, $database->name, 1); + $this->clientProphecy->addOption('sink', $localFilepath)->shouldBeCalled(); + $this->clientProphecy->addOption('curl.options', [ + 'CURLOPT_FILE' => $localFilepath, + 'CURLOPT_RETURNTRANSFER' => false, + ])->shouldBeCalled(); + $this->clientProphecy->addOption('progress', Argument::type('Closure'))->shouldBeCalled(); + $this->clientProphecy->addOption('on_stats', Argument::type('Closure'))->shouldBeCalled(); + $this->clientProphecy->getOptions()->willReturn([]); + + // Set codebase UUID and mock codebase API calls. + $codebaseUuid = '11111111-041c-44c7-a486-7972ed2cafc8'; + putenv('AH_CODEBASE_UUID=' . $codebaseUuid); + + $codebase = $this->getMockCodeBaseResponse(); + $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid) + ->willReturn($codebase) + ->shouldBeCalled(); + + // Create an invalid (non-gzip) file. + $content = 'This is plain text, not gzipped'; + file_put_contents($localFilepath, $content); + + // Mock HTTP response with 200 success. + $response = $this->prophet->prophesize(ResponseInterface::class); + $response->getStatusCode()->willReturn(200); + $downloadUrl = $backup->links->download->href; + $this->httpClientProphecy->request('GET', $downloadUrl, Argument::type('array')) + ->willReturn($response->reveal()) + ->shouldBeCalled(); + } + + public function mockGetBackupWithEmptyFile(): void + { + $applications = $this->mockRequest('getApplications'); + $environments = $this->mockRequest('getApplicationEnvironments', $applications[0]->uuid); + $environment = $environments[0]; + $databases = $this->mockRequest('getEnvironmentsDatabases', $environment->id); + $database = $databases[0]; + $tamper = static function ($backups): void { + $backups[0]->completedAt = $backups[0]->completed_at; + }; + $backups = new BackupsResponse( + $this->mockRequest('getEnvironmentsDatabaseBackups', [ + $environment->id, + 'my_db', + ], null, null, $tamper) + ); + $backup = $backups[0]; + + $filename = implode('-', [ + $environment->name, + $database->name, + 'my_dbdev', + $backup->completedAt, + ]) . '.sql.gz'; + $localFilepath = Path::join(sys_get_temp_dir(), $filename); + + $this->mockDownloadBackupResponse($environment, $database->name, 1); + $this->clientProphecy->addOption('sink', $localFilepath)->shouldBeCalled(); + $this->clientProphecy->addOption('curl.options', [ + 'CURLOPT_FILE' => $localFilepath, + 'CURLOPT_RETURNTRANSFER' => false, + ])->shouldBeCalled(); + $this->clientProphecy->addOption('progress', Argument::type('Closure'))->shouldBeCalled(); + $this->clientProphecy->addOption('on_stats', Argument::type('Closure'))->shouldBeCalled(); + $this->clientProphecy->getOptions()->willReturn([]); + + // Set codebase UUID and mock codebase API calls. + $codebaseUuid = '11111111-041c-44c7-a486-7972ed2cafc8'; + putenv('AH_CODEBASE_UUID=' . $codebaseUuid); + + $codebase = $this->getMockCodeBaseResponse(); + $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid) + ->willReturn($codebase) + ->shouldBeCalled(); + + // Create an empty file. + file_put_contents($localFilepath, ''); + + // Mock HTTP response with 200 success. + $response = $this->prophet->prophesize(ResponseInterface::class); + $response->getStatusCode()->willReturn(200); + $downloadUrl = $backup->links->download->href; + $this->httpClientProphecy->request('GET', $downloadUrl, Argument::type('array')) + ->willReturn($response->reveal()) + ->shouldBeCalled(); + } + protected function mockDownloadBackup(object $database, object $environment, object $backup, int $curlCode = 0): object { if ($curlCode) { From 0f22eba2f69ff8c9160a27eb116b7abfb1694635 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 28 Jan 2026 12:45:07 +0530 Subject: [PATCH 04/12] code cov improvment --- .../src/Commands/Pull/PullCommandTestBase.php | 18 ++++++++++++++++ .../Commands/Pull/PullDatabaseCommandTest.php | 21 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index e172f191a..d604cdb17 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -432,6 +432,12 @@ public function mockGetBackupWithHttpError(): void ->willReturn($codebase) ->shouldBeCalled(); + // Mock codebase environments. + $codebaseEnv = $this->getMockCodeBaseEnvironment(); + $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid . '/environments') + ->willReturn([$codebaseEnv]) + ->shouldBeCalled(); + // Mock HTTP response with 404 error. $response = $this->prophet->prophesize(ResponseInterface::class); $response->getStatusCode()->willReturn(404); @@ -486,6 +492,12 @@ public function mockGetBackupWithInvalidGzip(): void ->willReturn($codebase) ->shouldBeCalled(); + // Mock codebase environments. + $codebaseEnv = $this->getMockCodeBaseEnvironment(); + $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid . '/environments') + ->willReturn([$codebaseEnv]) + ->shouldBeCalled(); + // Create an invalid (non-gzip) file. $content = 'This is plain text, not gzipped'; file_put_contents($localFilepath, $content); @@ -544,6 +556,12 @@ public function mockGetBackupWithEmptyFile(): void ->willReturn($codebase) ->shouldBeCalled(); + // Mock codebase environments. + $codebaseEnv = $this->getMockCodeBaseEnvironment(); + $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid . '/environments') + ->willReturn([$codebaseEnv]) + ->shouldBeCalled(); + // Create an empty file. file_put_contents($localFilepath, ''); diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 9d79eebec..e6cbe9f19 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,6 +322,26 @@ public function testPullDatabaseWithInvalidSslCertificate(int $errorCode): void $this->assertStringContainsString('Trying alternative host other.example.com', $output); } + /** + * 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()); + } + 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 { $applicationsResponse = $this->mockApplicationsRequest(); From 343becc6045871317151344c827cc6127582cbc8 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 28 Jan 2026 12:56:04 +0530 Subject: [PATCH 05/12] code cov improvement --- .../src/Commands/Pull/PullCommandTestBase.php | 216 ++---------------- .../Commands/Pull/PullDatabaseCommandTest.php | 56 ++++- 2 files changed, 77 insertions(+), 195 deletions(-) diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index d604cdb17..31d4a24da 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -387,194 +387,7 @@ public function mockGetBackup(mixed $environment): void $this->mockDownloadBackup($databases[0], $environment, $backups[0]); } - public function mockGetBackupWithHttpError(): void - { - $applications = $this->mockRequest('getApplications'); - $environments = $this->mockRequest('getApplicationEnvironments', $applications[0]->uuid); - $environment = $environments[0]; - $databases = $this->mockRequest('getEnvironmentsDatabases', $environment->id); - $database = $databases[0]; - $tamper = static function ($backups): void { - $backups[0]->completedAt = $backups[0]->completed_at; - }; - $backups = new BackupsResponse( - $this->mockRequest('getEnvironmentsDatabaseBackups', [ - $environment->id, - 'my_db', - ], null, null, $tamper) - ); - $backup = $backups[0]; - - $filename = implode('-', [ - $environment->name, - $database->name, - 'my_dbdev', - $backup->completedAt, - ]) . '.sql.gz'; - $localFilepath = Path::join(sys_get_temp_dir(), $filename); - - $this->mockDownloadBackupResponse($environment, $database->name, 1); - $this->clientProphecy->addOption('sink', $localFilepath)->shouldBeCalled(); - $this->clientProphecy->addOption('curl.options', [ - 'CURLOPT_FILE' => $localFilepath, - 'CURLOPT_RETURNTRANSFER' => false, - ])->shouldBeCalled(); - $this->clientProphecy->addOption('progress', Argument::type('Closure'))->shouldBeCalled(); - $this->clientProphecy->addOption('on_stats', Argument::type('Closure'))->shouldBeCalled(); - $this->clientProphecy->getOptions()->willReturn([]); - - // Set codebase UUID and mock codebase API calls. - $codebaseUuid = '11111111-041c-44c7-a486-7972ed2cafc8'; - putenv('AH_CODEBASE_UUID=' . $codebaseUuid); - - $codebase = $this->getMockCodeBaseResponse(); - $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid) - ->willReturn($codebase) - ->shouldBeCalled(); - - // Mock codebase environments. - $codebaseEnv = $this->getMockCodeBaseEnvironment(); - $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid . '/environments') - ->willReturn([$codebaseEnv]) - ->shouldBeCalled(); - - // Mock HTTP response with 404 error. - $response = $this->prophet->prophesize(ResponseInterface::class); - $response->getStatusCode()->willReturn(404); - $downloadUrl = $backup->links->download->href; - $this->httpClientProphecy->request('GET', $downloadUrl, Argument::type('array')) - ->willReturn($response->reveal()) - ->shouldBeCalled(); - } - - public function mockGetBackupWithInvalidGzip(): void - { - $applications = $this->mockRequest('getApplications'); - $environments = $this->mockRequest('getApplicationEnvironments', $applications[0]->uuid); - $environment = $environments[0]; - $databases = $this->mockRequest('getEnvironmentsDatabases', $environment->id); - $database = $databases[0]; - $tamper = static function ($backups): void { - $backups[0]->completedAt = $backups[0]->completed_at; - }; - $backups = new BackupsResponse( - $this->mockRequest('getEnvironmentsDatabaseBackups', [ - $environment->id, - 'my_db', - ], null, null, $tamper) - ); - $backup = $backups[0]; - - $filename = implode('-', [ - $environment->name, - $database->name, - 'my_dbdev', - $backup->completedAt, - ]) . '.sql.gz'; - $localFilepath = Path::join(sys_get_temp_dir(), $filename); - - $this->mockDownloadBackupResponse($environment, $database->name, 1); - $this->clientProphecy->addOption('sink', $localFilepath)->shouldBeCalled(); - $this->clientProphecy->addOption('curl.options', [ - 'CURLOPT_FILE' => $localFilepath, - 'CURLOPT_RETURNTRANSFER' => false, - ])->shouldBeCalled(); - $this->clientProphecy->addOption('progress', Argument::type('Closure'))->shouldBeCalled(); - $this->clientProphecy->addOption('on_stats', Argument::type('Closure'))->shouldBeCalled(); - $this->clientProphecy->getOptions()->willReturn([]); - - // Set codebase UUID and mock codebase API calls. - $codebaseUuid = '11111111-041c-44c7-a486-7972ed2cafc8'; - putenv('AH_CODEBASE_UUID=' . $codebaseUuid); - - $codebase = $this->getMockCodeBaseResponse(); - $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid) - ->willReturn($codebase) - ->shouldBeCalled(); - - // Mock codebase environments. - $codebaseEnv = $this->getMockCodeBaseEnvironment(); - $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid . '/environments') - ->willReturn([$codebaseEnv]) - ->shouldBeCalled(); - - // Create an invalid (non-gzip) file. - $content = 'This is plain text, not gzipped'; - file_put_contents($localFilepath, $content); - - // Mock HTTP response with 200 success. - $response = $this->prophet->prophesize(ResponseInterface::class); - $response->getStatusCode()->willReturn(200); - $downloadUrl = $backup->links->download->href; - $this->httpClientProphecy->request('GET', $downloadUrl, Argument::type('array')) - ->willReturn($response->reveal()) - ->shouldBeCalled(); - } - - public function mockGetBackupWithEmptyFile(): void - { - $applications = $this->mockRequest('getApplications'); - $environments = $this->mockRequest('getApplicationEnvironments', $applications[0]->uuid); - $environment = $environments[0]; - $databases = $this->mockRequest('getEnvironmentsDatabases', $environment->id); - $database = $databases[0]; - $tamper = static function ($backups): void { - $backups[0]->completedAt = $backups[0]->completed_at; - }; - $backups = new BackupsResponse( - $this->mockRequest('getEnvironmentsDatabaseBackups', [ - $environment->id, - 'my_db', - ], null, null, $tamper) - ); - $backup = $backups[0]; - - $filename = implode('-', [ - $environment->name, - $database->name, - 'my_dbdev', - $backup->completedAt, - ]) . '.sql.gz'; - $localFilepath = Path::join(sys_get_temp_dir(), $filename); - - $this->mockDownloadBackupResponse($environment, $database->name, 1); - $this->clientProphecy->addOption('sink', $localFilepath)->shouldBeCalled(); - $this->clientProphecy->addOption('curl.options', [ - 'CURLOPT_FILE' => $localFilepath, - 'CURLOPT_RETURNTRANSFER' => false, - ])->shouldBeCalled(); - $this->clientProphecy->addOption('progress', Argument::type('Closure'))->shouldBeCalled(); - $this->clientProphecy->addOption('on_stats', Argument::type('Closure'))->shouldBeCalled(); - $this->clientProphecy->getOptions()->willReturn([]); - - // Set codebase UUID and mock codebase API calls. - $codebaseUuid = '11111111-041c-44c7-a486-7972ed2cafc8'; - putenv('AH_CODEBASE_UUID=' . $codebaseUuid); - - $codebase = $this->getMockCodeBaseResponse(); - $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid) - ->willReturn($codebase) - ->shouldBeCalled(); - - // Mock codebase environments. - $codebaseEnv = $this->getMockCodeBaseEnvironment(); - $this->clientProphecy->request('get', '/codebases/' . $codebaseUuid . '/environments') - ->willReturn([$codebaseEnv]) - ->shouldBeCalled(); - - // Create an empty file. - file_put_contents($localFilepath, ''); - - // Mock HTTP response with 200 success. - $response = $this->prophet->prophesize(ResponseInterface::class); - $response->getStatusCode()->willReturn(200); - $downloadUrl = $backup->links->download->href; - $this->httpClientProphecy->request('GET', $downloadUrl, Argument::type('array')) - ->willReturn($response->reveal()) - ->shouldBeCalled(); - } - - 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); @@ -609,10 +422,29 @@ protected function mockDownloadBackup(object $database, object $environment, obj ]) . '.sql.gz'; $localFilepath = Path::join(sys_get_temp_dir(), $filename); - // Create a valid gzip file for validation. - $content = 'Mock SQL dump content for testing'; - $gzippedContent = gzencode($content); - file_put_contents($localFilepath, $gzippedContent); + // 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; + 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(); diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index e6cbe9f19..036030e12 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -342,7 +342,46 @@ public function testBackupDownloadUrlGetterSetter(): void $this->assertEquals((string) $uri, (string) $this->command->getBackupDownloadUrl()); } - 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 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); + } + + 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(); @@ -357,7 +396,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(); @@ -379,7 +418,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. From 3bd4c75b1bdacc5e9891672fb0976f87a4b4c5ad Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 28 Jan 2026 13:27:53 +0530 Subject: [PATCH 06/12] code cov fix --- .../src/Commands/Pull/PullCommandTestBase.php | 36 ++++++-- .../Commands/Pull/PullDatabaseCommandTest.php | 85 +++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 31d4a24da..21f6f06b2 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -438,6 +438,10 @@ protected function mockDownloadBackup(object $database, object $environment, obj 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'; @@ -460,7 +464,8 @@ 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', @@ -487,7 +492,13 @@ 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); - $response->getStatusCode()->willReturn(200); + + // Set the HTTP status code based on validation error. + if ($validationError === 'http_error') { + $response->getStatusCode()->willReturn(500); + } else { + $response->getStatusCode()->willReturn(200); + } $capturedOpts = null; $this->httpClientProphecy @@ -510,11 +521,22 @@ protected function mockDownloadCodebaseBackup(object $database, string $url, obj return true; }) ) - ->will(function () use (&$capturedOpts, $response, $localFilepath): ResponseInterface { - // Create a valid gzip file for validation. - $content = 'Mock SQL dump content for testing'; - $gzippedContent = gzencode($content); - file_put_contents($localFilepath, $gzippedContent); + ->will(function () use (&$capturedOpts, $response, $localFilepath, $validationError): 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); + file_put_contents($localFilepath, $gzippedContent); + break; + default: + // Create a valid gzip file for validation. + $content = 'Mock SQL dump content for testing'; + $gzippedContent = gzencode($content); + file_put_contents($localFilepath, $gzippedContent); + break; + } // Simulate the download to force progress rendering. $progress = $capturedOpts['progress']; diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 036030e12..56fd2a359 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -381,6 +381,91 @@ public function testPullDatabaseWithMissingFile(): void $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(); From 4275303962693cb44d0d6cc988af79b62ce6fcfc Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 28 Jan 2026 13:33:48 +0530 Subject: [PATCH 07/12] corrected import --- src/Command/Pull/PullCommandBase.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 92fd10817..4d0ea6c98 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -33,9 +33,11 @@ use Psr\Log\LoggerInterface; use React\EventLoop\Loop; use SelfUpdate\SelfUpdateManager; +use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\ConsoleSectionOutput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Path; @@ -429,7 +431,7 @@ private function validateGzipFile(string $localFilepath): void public static function displayDownloadProgress(mixed $totalBytes, mixed $downloadedBytes, mixed &$progress, OutputInterface $output): void { if ($totalBytes > 0 && is_null($progress)) { - $progress = new \Symfony\Component\Console\Helper\ProgressBar($output, $totalBytes); + $progress = new ProgressBar($output, $totalBytes); $progress->setFormat(' %current%/%max% [%bar%] %percent:3s%%'); $progress->setProgressCharacter('💧'); $progress->setOverwrite(true); @@ -439,7 +441,7 @@ public static function displayDownloadProgress(mixed $totalBytes, mixed $downloa if (!is_null($progress)) { if ($totalBytes === $downloadedBytes && $progress->getProgressPercent() !== 1.0) { $progress->finish(); - if ($output instanceof \Symfony\Component\Console\Output\ConsoleSectionOutput) { + if ($output instanceof ConsoleSectionOutput) { $output->clear(); } return; From 39a83339aa1759fc8d632144fd47e63afe5f1c3b Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Thu, 29 Jan 2026 11:45:54 +0530 Subject: [PATCH 08/12] updated testcases for windows --- src/Command/Pull/PullCommandBase.php | 10 ++++++---- .../src/Commands/Pull/PullCommandTestBase.php | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 4d0ea6c98..b189a55e7 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -118,19 +118,21 @@ 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; } + if (PHP_OS_FAMILY === 'Windows') { + $completedAtFormatted = str_replace(['T', ':'], ['_', '-'], substr($backupResponse->completedAt, 0, 19)); + } else { + $completedAtFormatted = $backupResponse->completedAt; + } $filename = implode('-', [ $environment->name, $database->name, $dbMachineName, - $backupResponse->completedAt, + $completedAtFormatted, ]) . '.sql.gz'; return Path::join(sys_get_temp_dir(), $filename); } diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 21f6f06b2..eec265112 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -414,11 +414,16 @@ protected function mockDownloadBackup(object $database, object $environment, obj } else { $dbMachineName = 'db' . $database->id; } + if (PHP_OS_FAMILY === 'Windows') { + $completedAtFormatted = str_replace(['T', ':'], ['_', '-'], substr($backup->completedAt, 0, 19)); + } else { + $completedAtFormatted = $backup->completedAt; + } $filename = implode('-', [ $environment->name, $database->name, $dbMachineName, - $backup->completedAt, + $completedAtFormatted, ]) . '.sql.gz'; $localFilepath = Path::join(sys_get_temp_dir(), $filename); @@ -467,11 +472,16 @@ protected function mockDownloadBackup(object $database, object $environment, obj protected function mockDownloadCodebaseBackup(object $database, string $url, object $backup, int $curlCode = 0, string $validationError = ''): object { + if (PHP_OS_FAMILY === 'Windows') { + $completedAtFormatted = str_replace(['T', ':'], ['_', '-'], substr($backup->completedAt ?? '2025-04-01T13:01:06.603Z', 0, 19)); + } else { + $completedAtFormatted = $backup->completedAt ?? '2025-04-01T13:01:06.603Z'; + } $filename = implode('-', [ 'environment_3e8ecbec-ea7c-4260-8414-ef2938c859bc', $database->name ?? 'example', 'dbexample', - '2025-04-01T13:01:06.603Z', + $completedAtFormatted, ]) . '.sql.gz'; $localFilepath = Path::join(sys_get_temp_dir(), $filename); From c767a921a64d23e80aa78c7f643cbf0119014ea8 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Thu, 29 Jan 2026 22:20:45 +0530 Subject: [PATCH 09/12] widows test fix --- src/Command/Pull/PullCommandBase.php | 16 +++--- .../src/Commands/Pull/PullCommandTestBase.php | 54 ++++++++++++------- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index b189a55e7..6839bb771 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -124,16 +124,18 @@ public static function getBackupPath(object $environment, DatabaseResponse $data $dbMachineName = 'db' . $database->id; } if (PHP_OS_FAMILY === 'Windows') { - $completedAtFormatted = str_replace(['T', ':'], ['_', '-'], substr($backupResponse->completedAt, 0, 19)); + // 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'; } - $filename = implode('-', [ - $environment->name, - $database->name, - $dbMachineName, - $completedAtFormatted, - ]) . '.sql.gz'; return Path::join(sys_get_temp_dir(), $filename); } diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index eec265112..3da7918fe 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; + } + // 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; } - foreach ($expectedEnv as $k => $v) { - if (!array_key_exists($k, $env) || $env[$k] !== $v) { - 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; }) @@ -415,16 +427,18 @@ protected function mockDownloadBackup(object $database, object $environment, obj $dbMachineName = 'db' . $database->id; } if (PHP_OS_FAMILY === 'Windows') { - $completedAtFormatted = str_replace(['T', ':'], ['_', '-'], substr($backup->completedAt, 0, 19)); + // 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'; } - $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. From ccb527af0f8dd22c4ebd09ffa11f991130e9147f Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Thu, 29 Jan 2026 22:47:59 +0530 Subject: [PATCH 10/12] windows test failing: fix --- .../src/Commands/Pull/PullCommandTestBase.php | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 3da7918fe..44707bf7c 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -515,14 +515,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); - - // Set the HTTP status code based on validation error. - if ($validationError === 'http_error') { - $response->getStatusCode()->willReturn(500); - } else { - $response->getStatusCode()->willReturn(200); - } + $statusCode = $validationError === 'http_error' ? 500 : 200; + $response = new \GuzzleHttp\Psr7\Response($statusCode); $capturedOpts = null; $this->httpClientProphecy @@ -545,29 +539,35 @@ protected function mockDownloadCodebaseBackup(object $database, string $url, obj return true; }) ) - ->will(function () use (&$capturedOpts, $response, $localFilepath, $validationError): 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); - file_put_contents($localFilepath, $gzippedContent); + 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); - file_put_contents($localFilepath, $gzippedContent); + 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(); From 443396991c4d35a5603df0ae84c18529dd6bc744 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Thu, 29 Jan 2026 22:58:54 +0530 Subject: [PATCH 11/12] windows test fix --- .../src/Commands/Pull/PullCommandTestBase.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 44707bf7c..555692d72 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -486,15 +486,23 @@ protected function mockDownloadBackup(object $database, object $environment, obj protected function mockDownloadCodebaseBackup(object $database, string $url, object $backup, int $curlCode = 0, string $validationError = ''): object { + // 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'); + } + if (PHP_OS_FAMILY === 'Windows') { $completedAtFormatted = str_replace(['T', ':'], ['_', '-'], substr($backup->completedAt ?? '2025-04-01T13:01:06.603Z', 0, 19)); } else { $completedAtFormatted = $backup->completedAt ?? '2025-04-01T13:01:06.603Z'; } $filename = implode('-', [ - 'environment_3e8ecbec-ea7c-4260-8414-ef2938c859bc', + $environment->name, $database->name ?? 'example', - 'dbexample', + $dbMachineName, $completedAtFormatted, ]) . '.sql.gz'; $localFilepath = Path::join(sys_get_temp_dir(), $filename); From c29386254a501af8bd1d77a6576aef3c9cf7db53 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Fri, 30 Jan 2026 10:31:45 +0530 Subject: [PATCH 12/12] windows test fix --- .../src/Commands/Pull/PullCommandTestBase.php | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 555692d72..22595011a 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -494,17 +494,21 @@ protected function mockDownloadCodebaseBackup(object $database, string $url, obj $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') { - $completedAtFormatted = str_replace(['T', ':'], ['_', '-'], substr($backup->completedAt ?? '2025-04-01T13:01:06.603Z', 0, 19)); + $hash = substr(md5($environment->name . ($database->name ?? 'example') . $dbMachineName . $completedAtFormatted), 0, 8); + $filename = $hash . '.sql.gz'; } else { - $completedAtFormatted = $backup->completedAt ?? '2025-04-01T13:01:06.603Z'; + $filename = implode('-', [ + $environment->name, + $database->name ?? 'example', + $dbMachineName, + $completedAtFormatted, + ]) . '.sql.gz'; } - $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.