From faaad8adfba6dcf7ce019a4a192b889150bba59c Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 28 Jan 2026 10:57:24 +0530 Subject: [PATCH 01/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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. From c6ab7ff8060101e6e25a22ba7668dc029b2abda8 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Mon, 9 Feb 2026 11:41:14 +0530 Subject: [PATCH 13/26] addressed co pilot reviews --- src/Command/Pull/PullCommandBase.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 6839bb771..a317763c7 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -125,6 +125,7 @@ public static function getBackupPath(object $environment, DatabaseResponse $data } if (PHP_OS_FAMILY === 'Windows') { // Use short filename to comply with 8.3 format and avoid long path issues. + // The hash-based filename still preserves the '.sql.gz' extension. $hash = substr(md5($environment->name . $database->name . $dbMachineName . $backupResponse->completedAt), 0, 8); $filename = $hash . '.sql.gz'; } else { @@ -389,7 +390,7 @@ private function validateDownloadedFile(string $localFilepath): void } // Optional: Validate gzip file header (backup files are .sql.gz) - if (str_ends_with($localFilepath, '.gz')) { + if (str_ends_with($localFilepath, '.sql.gz')) { $this->validateGzipFile($localFilepath); } } @@ -427,7 +428,7 @@ private function validateGzipFile(string $localFilepath): void 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.' + 'The downloaded file is not a valid gzip archive' ); } } From 394f17e5f489856155488b848b0c853d1352fcd4 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Tue, 10 Feb 2026 22:44:38 +0530 Subject: [PATCH 14/26] addressed reviews: removed gzip and empty file validations --- src/Command/Pull/PullCommandBase.php | 57 +------------------ .../src/Commands/Pull/PullCommandTestBase.php | 8 --- .../Commands/Pull/PullDatabaseCommandTest.php | 46 +-------------- 3 files changed, 4 insertions(+), 107 deletions(-) diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index a317763c7..2dff93833 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -365,7 +365,7 @@ private function validateDownloadResponse(object $response, string $localFilepat } /** - * Validates that the downloaded backup file exists and is not empty. + * Validates that the downloaded backup file exists. * * @param string $localFilepath The local file path to validate * @throws \Acquia\Cli\Exception\AcquiaCliException If the file is invalid @@ -375,62 +375,11 @@ private function validateDownloadedFile(string $localFilepath): void // 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.' + 'Database backup download failed: file was not created.' ); } - // 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, '.sql.gz')) { - $this->validateGzipFile($localFilepath); - } - } - - /** - * 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 - { - // 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( - 'The downloaded file is not a valid gzip archive' - ); - } + // File exists - assume it's valid since it comes from trusted Acquia API. } 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 22595011a..35b25958a 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -443,14 +443,6 @@ protected function mockDownloadBackup(object $database, object $environment, obj // 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)) { diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 56fd2a359..c01f1e2bb 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -342,32 +342,6 @@ public function testBackupDownloadUrlGetterSetter(): void $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. */ @@ -381,19 +355,6 @@ 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). */ @@ -507,12 +468,7 @@ protected function setupPullDatabase(bool $mysqlConnectSuccessful, bool $mockIde // 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(); - } + // Note: 'missing' file validation doesn't need filesystem cleanup since no file is created. return; } From 95d55fe38d46ba5cd7528f0ce5747057ae37ce6b Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Tue, 10 Feb 2026 22:59:27 +0530 Subject: [PATCH 15/26] mutation fix --- tests/phpunit/src/Commands/Pull/PullCommandTestBase.php | 4 ---- .../phpunit/src/Commands/Pull/PullDatabaseCommandTest.php | 7 ++++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 35b25958a..9bb1d864c 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -449,10 +449,6 @@ 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'; diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index c01f1e2bb..a60830a29 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -468,7 +468,12 @@ protected function setupPullDatabase(bool $mysqlConnectSuccessful, bool $mockIde // If there's a validation error, we don't need to mock the rest of the database operations. if ($validationError) { - // Note: 'missing' file validation doesn't need filesystem cleanup since no file is created. + // 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; } From e9198b3516a0a51ab1191868246a8c6bc78f21b9 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Tue, 10 Feb 2026 23:07:32 +0530 Subject: [PATCH 16/26] mutation fix --- .../Commands/Pull/PullDatabaseCommandTest.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index a60830a29..e247b402f 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -754,4 +754,38 @@ public function testPullDatabasesWithCodebaseUuidOnDemand(): void self::unsetEnvVars(['AH_CODEBASE_UUID']); } + + /** + * Test that kills the PublicVisibility mutant for getBackupDownloadUrl method. + * This test ensures the method remains public and accessible from outside the class. + */ + public function testPublicVisibilityMutantKillerForGetBackupDownloadUrl(): void + { + + $reflection = new \ReflectionMethod($this->command, 'getBackupDownloadUrl'); + $this->assertTrue($reflection->isPublic(), 'getBackupDownloadUrl method must remain public'); + + $this->assertNull($this->command->getBackupDownloadUrl()); + + $testUrl = 'https://example.com/backup.sql.gz'; + $this->command->setBackupDownloadUrl($testUrl); + $this->assertEquals($testUrl, (string) $this->command->getBackupDownloadUrl()); + } + + /** + * Test that kills the MethodCallRemoval mutant for displayDownloadProgress static method call. + * This test ensures the static method call cannot be removed without breaking functionality. + */ + public function testMethodCallRemovalMutantKillerForDisplayDownloadProgress(): void + { + $output = new BufferedOutput(); + $progress = null; + + PullCommandBase::displayDownloadProgress(100, 50, $progress, $output); + + $displayedOutput = $output->fetch(); + $this->assertStringContainsString('50/100', $displayedOutput, 'displayDownloadProgress static method call must not be removed'); + $this->assertStringContainsString('50%', $displayedOutput, 'Progress percentage must be displayed'); + $this->assertStringContainsString('💧', $displayedOutput, 'Progress indicator must be displayed'); + } } From 79f56b7b353ff7b8d9fd10a041bc7c0beda7a8c2 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Tue, 10 Feb 2026 23:12:47 +0530 Subject: [PATCH 17/26] mutation fix --- tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index e247b402f..0d3edc132 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -781,6 +781,11 @@ public function testMethodCallRemovalMutantKillerForDisplayDownloadProgress(): v $output = new BufferedOutput(); $progress = null; + PullCommandBase::displayDownloadProgress(100, 0, $progress, $output); + $this->assertStringContainsString('0/100', $output->fetch(), 'Initial progress display must work'); + + sleep(1); + PullCommandBase::displayDownloadProgress(100, 50, $progress, $output); $displayedOutput = $output->fetch(); From eee2337b41bb7e220de8a6d25e3295da25e1a915 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Tue, 10 Feb 2026 23:41:37 +0530 Subject: [PATCH 18/26] mutation fix --- .../Commands/Pull/PullDatabaseCommandTest.php | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 0d3edc132..c0f57ef16 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -10,6 +10,7 @@ use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Helpers\SshHelper; use Acquia\Cli\Transformer\EnvironmentTransformer; +use AcquiaCloudApi\Response\DatabaseResponse; use AcquiaCloudApi\Response\SiteInstanceDatabaseBackupResponse; use AcquiaCloudApi\Response\SiteInstanceDatabaseResponse; use GuzzleHttp\Client; @@ -756,20 +757,41 @@ public function testPullDatabasesWithCodebaseUuidOnDemand(): void } /** - * Test that kills the PublicVisibility mutant for getBackupDownloadUrl method. - * This test ensures the method remains public and accessible from outside the class. + * Test that kills the PublicVisibility mutant for getBackupPath static method. + * This test ensures the static method remains public and accessible from outside the class. */ - public function testPublicVisibilityMutantKillerForGetBackupDownloadUrl(): void + public function testPublicVisibilityMutantKillerForGetBackupPath(): void { + // Ensure the method is public and accessible from outside the class. + $reflection = new \ReflectionMethod(PullCommandBase::class, 'getBackupPath'); + $this->assertTrue($reflection->isPublic(), 'getBackupPath method must remain public'); + + // Create mock objects for testing the static method call. + $environment = (object) ['name' => 'test-env']; + + // Create a proper DatabaseResponse object with all required properties. + $databaseData = (object) [ + 'db_host' => 'localhost', + 'environment' => (object) ['name' => 'test-env'], + 'flags' => (object) ['default' => true], + 'id' => '123', + 'name' => 'test_db', + 'password' => null, + 'ssh_host' => null, + 'url' => null, + 'user_name' => null, + ]; + $database = new DatabaseResponse($databaseData); - $reflection = new \ReflectionMethod($this->command, 'getBackupDownloadUrl'); - $this->assertTrue($reflection->isPublic(), 'getBackupDownloadUrl method must remain public'); + $backupResponse = (object) ['completedAt' => '2023-01-01T12:00:00.000Z']; - $this->assertNull($this->command->getBackupDownloadUrl()); + // This call must work - if the method becomes protected, this will fail. + $backupPath = PullCommandBase::getBackupPath($environment, $database, $backupResponse); - $testUrl = 'https://example.com/backup.sql.gz'; - $this->command->setBackupDownloadUrl($testUrl); - $this->assertEquals($testUrl, (string) $this->command->getBackupDownloadUrl()); + $this->assertIsString($backupPath, 'getBackupPath must return a string'); + $this->assertStringContainsString('test-env', $backupPath, 'Backup path must contain environment name'); + $this->assertStringContainsString('test_db', $backupPath, 'Backup path must contain database name'); + $this->assertStringContainsString('.sql.gz', $backupPath, 'Backup path must have .sql.gz extension'); } /** From 32e43066bafae34a75130cbd4c2880c00b153dec Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Tue, 10 Feb 2026 23:50:11 +0530 Subject: [PATCH 19/26] mutation fix --- .../Commands/Pull/PullDatabaseCommandTest.php | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index c0f57ef16..85d1803db 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -789,30 +789,35 @@ public function testPublicVisibilityMutantKillerForGetBackupPath(): void $backupPath = PullCommandBase::getBackupPath($environment, $database, $backupResponse); $this->assertIsString($backupPath, 'getBackupPath must return a string'); - $this->assertStringContainsString('test-env', $backupPath, 'Backup path must contain environment name'); - $this->assertStringContainsString('test_db', $backupPath, 'Backup path must contain database name'); $this->assertStringContainsString('.sql.gz', $backupPath, 'Backup path must have .sql.gz extension'); + + // On Windows, the filename is a hash, on other platforms it contains env and db names. + if (PHP_OS_FAMILY === 'Windows') { + // On Windows, verify it contains the temp directory and a hash-based filename. + $this->assertStringContainsString(sys_get_temp_dir(), $backupPath, 'Backup path must be in temp directory'); + $this->assertMatchesRegularExpression('/[a-f0-9]{8}\.sql\.gz$/', basename($backupPath), 'Windows backup filename must be hash-based'); + } else { + // On non-Windows, verify it contains environment and database names. + $this->assertStringContainsString('test-env', $backupPath, 'Backup path must contain environment name'); + $this->assertStringContainsString('test_db', $backupPath, 'Backup path must contain database name'); + } } + /** - * Test that kills the MethodCallRemoval mutant for displayDownloadProgress static method call. - * This test ensures the static method call cannot be removed without breaking functionality. + * Test that kills the MethodCallRemoval mutant for validateDownloadedFile method call. + * This test ensures the validateDownloadedFile method call cannot be removed without breaking functionality. */ - public function testMethodCallRemovalMutantKillerForDisplayDownloadProgress(): void + public function testMethodCallRemovalMutantKillerForValidateDownloadedFile(): void { - $output = new BufferedOutput(); - $progress = null; - - PullCommandBase::displayDownloadProgress(100, 0, $progress, $output); - $this->assertStringContainsString('0/100', $output->fetch(), 'Initial progress display must work'); - - sleep(1); + // Create a scenario where validateDownloadedFile would throw an exception for a missing file. + $this->setupPullDatabase(true, false, false, true, false, 0, true, true, 'missing'); + $inputs = self::inputChooseEnvironment(); - PullCommandBase::displayDownloadProgress(100, 50, $progress, $output); + // If validateDownloadedFile method call is removed, this exception won't be thrown. + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Database backup download failed: file was not created'); - $displayedOutput = $output->fetch(); - $this->assertStringContainsString('50/100', $displayedOutput, 'displayDownloadProgress static method call must not be removed'); - $this->assertStringContainsString('50%', $displayedOutput, 'Progress percentage must be displayed'); - $this->assertStringContainsString('💧', $displayedOutput, 'Progress indicator must be displayed'); + $this->executeCommand(['--no-scripts' => true], $inputs); } } From 549cebfbe3a91f5e777ac7f9af2e524fe1779a1f Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Tue, 10 Feb 2026 23:57:50 +0530 Subject: [PATCH 20/26] windows test fix --- tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 85d1803db..837addb94 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -794,7 +794,10 @@ public function testPublicVisibilityMutantKillerForGetBackupPath(): void // On Windows, the filename is a hash, on other platforms it contains env and db names. if (PHP_OS_FAMILY === 'Windows') { // On Windows, verify it contains the temp directory and a hash-based filename. - $this->assertStringContainsString(sys_get_temp_dir(), $backupPath, 'Backup path must be in temp directory'); + // Normalize path separators for comparison (Windows can use both / and \) + $normalizedBackupPath = str_replace('\\', '/', $backupPath); + $normalizedTempDir = str_replace('\\', '/', sys_get_temp_dir()); + $this->assertStringContainsString($normalizedTempDir, $normalizedBackupPath, 'Backup path must be in temp directory'); $this->assertMatchesRegularExpression('/[a-f0-9]{8}\.sql\.gz$/', basename($backupPath), 'Windows backup filename must be hash-based'); } else { // On non-Windows, verify it contains environment and database names. From 262088be938808ea9c95da6614ca28d6e4b2d0c5 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 11 Feb 2026 00:02:49 +0530 Subject: [PATCH 21/26] mutation fix --- .../Commands/Pull/PullDatabaseCommandTest.php | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 837addb94..5cef14e09 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -823,4 +823,77 @@ public function testMethodCallRemovalMutantKillerForValidateDownloadedFile(): vo $this->executeCommand(['--no-scripts' => true], $inputs); } + + /** + * Test that kills the MethodCallRemoval mutant for validateDownloadedFile method call in validateDownloadResponse. + * This test ensures the validateDownloadedFile method call cannot be removed from the codebase download path. + */ + public function testMethodCallRemovalMutantKillerForValidateDownloadedFileInCodebasePath(): void + { + $codebaseUuid = '11111111-041c-44c7-a486-7972ed2cafc8'; + self::SetEnvVars(['AH_CODEBASE_UUID' => $codebaseUuid]); + + try { + // 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, + // This will trigger validateDownloadedFile through validateDownloadResponse. + 'missing' + ); + + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockExecuteMySqlConnect($localMachineHelper, true); + $inputs = self::inputChooseEnvironment(); + + // If validateDownloadedFile method call is removed from validateDownloadResponse, this exception won't be thrown. + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Database backup download failed: file was not created'); + + $this->executeCommand([ + '--no-scripts' => true, + '--on-demand' => false, + ], $inputs); + } finally { + self::unsetEnvVars(['AH_CODEBASE_UUID']); + } + } } From 3b22ba8e2596d1e15b58e83d501cb66e9d6f1ccb Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 11 Feb 2026 00:15:17 +0530 Subject: [PATCH 22/26] mutation fix --- .../src/Commands/Pull/PullCommandTestBase.php | 6 + .../Commands/Pull/PullDatabaseCommandTest.php | 113 +++++++++--------- 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 9bb1d864c..698dfafa5 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -542,6 +542,12 @@ protected function mockDownloadCodebaseBackup(object $database, string $url, obj ->will(function () use (&$capturedOpts, $response, $localFilepath, $validationError): \Psr\Http\Message\ResponseInterface { // Create file based on validation error type. switch ($validationError) { + case 'missing': + // Don't create a file to test missing file validation. + if (file_exists($localFilepath)) { + unlink($localFilepath); + } + break; case 'http_error': // For HTTP error, create file that will be cleaned up. $content = 'Mock SQL dump content for testing'; diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 5cef14e09..6a2fdd5ba 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -813,11 +813,13 @@ public function testPublicVisibilityMutantKillerForGetBackupPath(): void */ public function testMethodCallRemovalMutantKillerForValidateDownloadedFile(): void { - // Create a scenario where validateDownloadedFile would throw an exception for a missing file. + // Use the existing 'missing' validation error which specifically tests validateDownloadedFile + // This error type simulates a successful download where the file doesn't exist when validated. $this->setupPullDatabase(true, false, false, true, false, 0, true, true, 'missing'); $inputs = self::inputChooseEnvironment(); - // If validateDownloadedFile method call is removed, this exception won't be thrown. + // This exception is thrown by validateDownloadedFile when file is missing + // If the mutant removes the validateDownloadedFile call, this exception won't be thrown. $this->expectException(AcquiaCliException::class); $this->expectExceptionMessage('Database backup download failed: file was not created'); @@ -825,69 +827,66 @@ public function testMethodCallRemovalMutantKillerForValidateDownloadedFile(): vo } /** - * Test that kills the MethodCallRemoval mutant for validateDownloadedFile method call in validateDownloadResponse. - * This test ensures the validateDownloadedFile method call cannot be removed from the codebase download path. + * Test that downloading a backup with missing file fails with validation error (codebase path). + * This kills the MethodCallRemoval mutant for validateDownloadedFile inside validateDownloadResponse. */ - public function testMethodCallRemovalMutantKillerForValidateDownloadedFileInCodebasePath(): void + public function testPullDatabasesWithCodebaseUuidMissingFile(): 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, + 'missing' + ); + + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockExecuteMySqlConnect($localMachineHelper, true); + $inputs = self::inputChooseEnvironment(); + try { - // 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, - // This will trigger validateDownloadedFile through validateDownloadResponse. - 'missing' - ); - - $localMachineHelper = $this->mockLocalMachineHelper(); - $this->mockExecuteMySqlConnect($localMachineHelper, true); - $inputs = self::inputChooseEnvironment(); - - // If validateDownloadedFile method call is removed from validateDownloadResponse, this exception won't be thrown. $this->expectException(AcquiaCliException::class); $this->expectExceptionMessage('Database backup download failed: file was not created'); - $this->executeCommand([ '--no-scripts' => true, '--on-demand' => false, From 4dffde774b84ebc8aded6917024270d29d9dc687 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 11 Feb 2026 00:22:33 +0530 Subject: [PATCH 23/26] Update src/Command/Pull/PullCommandBase.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Command/Pull/PullCommandBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 2dff93833..d6610015d 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -355,7 +355,7 @@ private function validateDownloadResponse(object $response, string $localFilepat $this->localMachineHelper->getFilesystem()->remove($localFilepath); } throw new AcquiaCliException( - 'Database backup download failed with HTTP status {status}. Please try again or contact support.', + 'Database backup download failed with HTTP status {status}', ['status' => $statusCode] ); } From 5c42e98df598f3a07f7b5b394d6307584159b9fe Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 11 Feb 2026 00:47:34 +0530 Subject: [PATCH 24/26] addressed co pilot comments --- src/Command/Pull/PullCommandBase.php | 20 +++++------ .../src/Commands/Pull/PullCommandTestBase.php | 3 +- .../Commands/Pull/PullDatabaseCommandTest.php | 36 ++++++++++--------- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index d6610015d..946586355 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -29,6 +29,7 @@ use Exception; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\TransferStats; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; use Psr\Log\LoggerInterface; use React\EventLoop\Loop; @@ -70,18 +71,16 @@ public function __construct( /** * Get the backup download URL. - * This is primarily used for testing purposes. */ - public function getBackupDownloadUrl(): ?UriInterface + protected 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 + protected function setBackupDownloadUrl(string|UriInterface $url): void { if (is_string($url)) { $this->backupDownloadUrl = new \GuzzleHttp\Psr7\Uri($url); @@ -116,7 +115,7 @@ private function listTablesQuoted(string $out): array return $tables; } - public static function getBackupPath(object $environment, DatabaseResponse $database, object $backupResponse): string + protected static function getBackupPath(object $environment, DatabaseResponse $database, object $backupResponse): string { if ($database->flags->default) { $dbMachineName = $database->name . $environment->name; @@ -340,16 +339,15 @@ static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $ou /** * 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 + private function validateDownloadResponse(ResponseInterface $response, string $localFilepath): void { $statusCode = $response->getStatusCode(); - // Check for successful HTTP response. - if ($statusCode !== 200) { + // Check for successful HTTP response (any 2xx status). + if ($statusCode < 200 || $statusCode >= 300) { // Clean up the potentially corrupted file. if (file_exists($localFilepath)) { $this->localMachineHelper->getFilesystem()->remove($localFilepath); @@ -375,14 +373,14 @@ private function validateDownloadedFile(string $localFilepath): void // Check if file exists. if (!file_exists($localFilepath)) { throw new AcquiaCliException( - 'Database backup download failed: file was not created.' + 'Database backup download failed: file was not created' ); } // File exists - assume it's valid since it comes from trusted Acquia API. } - public static function displayDownloadProgress(mixed $totalBytes, mixed $downloadedBytes, mixed &$progress, OutputInterface $output): void + protected static function displayDownloadProgress(mixed $totalBytes, mixed $downloadedBytes, mixed &$progress, OutputInterface $output): void { if ($totalBytes > 0 && is_null($progress)) { $progress = new ProgressBar($output, $totalBytes); diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 698dfafa5..c680c71af 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -417,7 +417,8 @@ protected function mockDownloadBackup(object $database, object $environment, obj $domainsResponse = self::getMockResponseFromSpec('/environments/{environmentId}/domains', 'get', 200); $this->clientProphecy->request('get', "/environments/$environment->id/domains") ->willReturn($domainsResponse->_embedded->items); - $this->command->setBackupDownloadUrl(new Uri('https://www.example.com/download-backup')); + $method = new \ReflectionMethod($this->command, 'setBackupDownloadUrl'); + $method->invoke($this->command, new Uri('https://www.example.com/download-backup')); } else { $this->mockDownloadBackupResponse($environment, $database->name, 1); } diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 6a2fdd5ba..1bf9a7e0d 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -328,19 +328,22 @@ public function testPullDatabaseWithInvalidSslCertificate(int $errorCode): void */ public function testBackupDownloadUrlGetterSetter(): void { + $getMethod = new \ReflectionMethod($this->command, 'getBackupDownloadUrl'); + $setMethod = new \ReflectionMethod($this->command, 'setBackupDownloadUrl'); + // Test initial state (null). - $this->assertNull($this->command->getBackupDownloadUrl()); + $this->assertNull($getMethod->invoke($this->command)); // 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()); + $setMethod->invoke($this->command, $backupUrl); + $this->assertNotNull($getMethod->invoke($this->command)); + $this->assertEquals($backupUrl, (string) $getMethod->invoke($this->command)); // 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()); + $setMethod->invoke($this->command, $uri); + $this->assertEquals((string) $uri, (string) $getMethod->invoke($this->command)); } /** @@ -509,15 +512,19 @@ public function testDownloadProgressDisplay(): void { $output = new BufferedOutput(); $progress = null; - PullCommandBase::displayDownloadProgress(100, 0, $progress, $output); + $method = new \ReflectionMethod(PullCommandBase::class, 'displayDownloadProgress'); + $args = [100, 0, &$progress, $output]; + $method->invokeArgs(null, $args); $this->assertStringContainsString('0/100 [💧---------------------------] 0%', $output->fetch()); // Need to sleep to prevent the default redraw frequency from skipping display. sleep(1); - PullCommandBase::displayDownloadProgress(100, 50, $progress, $output); + $args = [100, 50, &$progress, $output]; + $method->invokeArgs(null, $args); $this->assertStringContainsString('50/100 [==============💧-------------] 50%', $output->fetch()); - PullCommandBase::displayDownloadProgress(100, 100, $progress, $output); + $args = [100, 100, &$progress, $output]; + $method->invokeArgs(null, $args); $this->assertStringContainsString('100/100 [============================] 100%', $output->fetch()); } @@ -757,14 +764,11 @@ public function testPullDatabasesWithCodebaseUuidOnDemand(): void } /** - * Test that kills the PublicVisibility mutant for getBackupPath static method. - * This test ensures the static method remains public and accessible from outside the class. + * Test that getBackupPath returns a valid backup file path. */ - public function testPublicVisibilityMutantKillerForGetBackupPath(): void + public function testGetBackupPath(): void { - // Ensure the method is public and accessible from outside the class. $reflection = new \ReflectionMethod(PullCommandBase::class, 'getBackupPath'); - $this->assertTrue($reflection->isPublic(), 'getBackupPath method must remain public'); // Create mock objects for testing the static method call. $environment = (object) ['name' => 'test-env']; @@ -785,8 +789,8 @@ public function testPublicVisibilityMutantKillerForGetBackupPath(): void $backupResponse = (object) ['completedAt' => '2023-01-01T12:00:00.000Z']; - // This call must work - if the method becomes protected, this will fail. - $backupPath = PullCommandBase::getBackupPath($environment, $database, $backupResponse); + // Use reflection to invoke the protected static method. + $backupPath = $reflection->invoke(null, $environment, $database, $backupResponse); $this->assertIsString($backupPath, 'getBackupPath must return a string'); $this->assertStringContainsString('.sql.gz', $backupPath, 'Backup path must have .sql.gz extension'); From 051c5c6ff359a7e1630a21568a8b77a6db628920 Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 11 Feb 2026 10:09:39 +0530 Subject: [PATCH 25/26] mutation fix --- .../src/Commands/Pull/PullCommandTestBase.php | 9 +- .../Commands/Pull/PullDatabaseCommandTest.php | 93 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index c680c71af..822011c72 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -473,7 +473,7 @@ protected function mockDownloadBackup(object $database, object $environment, obj return $database; } - protected function mockDownloadCodebaseBackup(object $database, string $url, object $backup, int $curlCode = 0, string $validationError = ''): object + protected function mockDownloadCodebaseBackup(object $database, string $url, object $backup, int $curlCode = 0, string $validationError = '', int $httpStatusCode = 0): object { // Calculate dbMachineName the same way as getBackupPath. $environment = (object) ['name' => 'environment_3e8ecbec-ea7c-4260-8414-ef2938c859bc']; @@ -516,7 +516,12 @@ 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'; - $statusCode = $validationError === 'http_error' ? 500 : 200; + // Allow explicit HTTP status code override; otherwise infer from validationError. + if ($httpStatusCode !== 0) { + $statusCode = $httpStatusCode; + } else { + $statusCode = $validationError === 'http_error' ? 500 : 200; + } $response = new \GuzzleHttp\Psr7\Response($statusCode); $capturedOpts = null; diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 1bf9a7e0d..0c20cca9c 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -899,4 +899,97 @@ public function testPullDatabasesWithCodebaseUuidMissingFile(): void self::unsetEnvVars(['AH_CODEBASE_UUID']); } } + + /** + * Test that HTTP status code 300 is treated as an error (kills GreaterThanOrEqualTo mutant). + * + * The source code uses `$statusCode >= 300`, and the mutant changes it to `$statusCode > 300`. + * A test with status code 300 will pass with `>= 300` (correctly treating it as an error) + * but fail with `> 300` (incorrectly treating it as success). + */ + public function testPullDatabasesWithCodebaseUuidHttpStatus300(): 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, + '', + 300 + ); + + $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 300'); + $this->executeCommand([ + '--no-scripts' => true, + '--on-demand' => false, + ], $inputs); + } finally { + self::unsetEnvVars(['AH_CODEBASE_UUID']); + } + } + + /** + * Test that getBackupDownloadUrl, setBackupDownloadUrl, and getBackupPath are protected. + * This kills the ProtectedVisibility mutant that changes protected to public. + */ + public function testMethodVisibilityIsProtected(): void + { + $getBackupDownloadUrl = new \ReflectionMethod(PullCommandBase::class, 'getBackupDownloadUrl'); + $this->assertTrue($getBackupDownloadUrl->isProtected(), 'getBackupDownloadUrl must be protected'); + + $setBackupDownloadUrl = new \ReflectionMethod(PullCommandBase::class, 'setBackupDownloadUrl'); + $this->assertTrue($setBackupDownloadUrl->isProtected(), 'setBackupDownloadUrl must be protected'); + + $getBackupPath = new \ReflectionMethod(PullCommandBase::class, 'getBackupPath'); + $this->assertTrue($getBackupPath->isProtected(), 'getBackupPath must be protected'); + } } From 494e4cc93384ec2892eac9f3523f3ce79cf8898e Mon Sep 17 00:00:00 2001 From: Kalindi Adhiya Date: Wed, 11 Feb 2026 10:19:07 +0530 Subject: [PATCH 26/26] mutation fix --- tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 0c20cca9c..f621cb919 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -991,5 +991,8 @@ public function testMethodVisibilityIsProtected(): void $getBackupPath = new \ReflectionMethod(PullCommandBase::class, 'getBackupPath'); $this->assertTrue($getBackupPath->isProtected(), 'getBackupPath must be protected'); + + $displayDownloadProgress = new \ReflectionMethod(PullCommandBase::class, 'displayDownloadProgress'); + $this->assertTrue($displayDownloadProgress->isProtected(), 'displayDownloadProgress must be protected'); } }