Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 72 additions & 20 deletions src/Command/Pull/PullCommandBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,56 @@ private function doImportRemoteDatabase(
$this->localMachineHelper->getFilesystem()->remove($localFilepath);
}

/**
* Validates that a backup download URL is accessible.
*
* The method first issues a HEAD request to the given URL to verify that the
* resource exists without downloading the content. If the HEAD request fails
* (for example, if the server does not support HEAD or returns an error), it
* falls back to a GET request as a secondary validation step.
*
* @param string $downloadUrl
* The backup download URL to validate.
*
* @throws \Acquia\Cli\Exception\AcquiaCliException
*/
private function validateBackupLink(string $downloadUrl): void
{
try {
// Try HEAD request first (more efficient, doesn't download the file)
$response = $this->httpClient->request('HEAD', $downloadUrl, [
'http_errors' => false,
]);
$statusCode = $response->getStatusCode();
if ($statusCode !== 200) {
throw new AcquiaCliException(
'Database backup link is invalid or unavailable. Please try again or contact support.',
['statusCode' => $statusCode]
);
}
} catch (RequestException $exception) {
// If HEAD request fails (e.g., not supported), try GET request as fallback.
try {
$response = $this->httpClient->request('GET', $downloadUrl, [
'http_errors' => false,
'stream' => true,
]);
$statusCode = $response->getStatusCode();
if ($statusCode !== 200) {
throw new AcquiaCliException(
'Database backup link is invalid or unavailable. Please try again or contact support.',
['statusCode' => $statusCode]
);
}
} catch (Exception $getException) {
// If both HEAD and GET fail, throw a generic error.
throw new AcquiaCliException(
'Database backup link is invalid or unavailable. Please try again or contact support.'
);
}
}
}

private function downloadDatabaseBackup(
EnvironmentResponse $environment,
DatabaseResponse $database,
Expand All @@ -236,31 +286,14 @@ private function downloadDatabaseBackup(
} else {
$output = $this->output;
}
// These options tell curl to stream the file to disk rather than loading it into memory.
$acquiaCloudClient = $this->cloudApiClientService->getClient();
$acquiaCloudClient->addOption('sink', $localFilepath);
$acquiaCloudClient->addOption('curl.options', [
'CURLOPT_FILE' => $localFilepath,
'CURLOPT_RETURNTRANSFER' => false,
]);
$acquiaCloudClient->addOption(
'progress',
static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $output): void {
self::displayDownloadProgress($totalBytes, $downloadedBytes, $progress, $output);
}
);
// This is really just used to allow us to inject values for $url during testing.
// It should be empty during normal operations.
$url = $this->getBackupDownloadUrl();
$acquiaCloudClient->addOption('on_stats', function (TransferStats $stats) use (&$url): void {
$url = $stats->getEffectiveUri();
});

$url = null;
try {
$codebaseUuid = self::getCodebaseUuid();
if ($codebaseUuid) {
// Download the backup file directly from the provided URL.
$downloadUrl = $backupResponse->links->download->href;
// Validate backup link before attempting download.
$this->validateBackupLink($downloadUrl);
$this->httpClient->request('GET', $downloadUrl, [
'progress' => static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $output): void {
self::displayDownloadProgress($totalBytes, $downloadedBytes, $progress, $output);
Expand All @@ -269,6 +302,25 @@ static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $ou
]);
return $localFilepath;
}
// These options tell curl to stream the file to disk rather than loading it into memory.
$acquiaCloudClient = $this->cloudApiClientService->getClient();
$acquiaCloudClient->addOption('sink', $localFilepath);
$acquiaCloudClient->addOption('curl.options', [
'CURLOPT_FILE' => $localFilepath,
'CURLOPT_RETURNTRANSFER' => false,
]);
$acquiaCloudClient->addOption(
'progress',
static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $output): void {
self::displayDownloadProgress($totalBytes, $downloadedBytes, $progress, $output);
}
);
// This is really just used to allow us to inject values for $url during testing.
// It should be empty during normal operations.
$url = $this->getBackupDownloadUrl();
$acquiaCloudClient->addOption('on_stats', function (TransferStats $stats) use (&$url): void {
$url = $stats->getEffectiveUri();
});
$acquiaCloudClient->stream(
"get",
"/environments/$environment->uuid/databases/$database->name/backups/$backupResponse->id/actions/download",
Expand Down
32 changes: 19 additions & 13 deletions tests/phpunit/src/Commands/Pull/PullCommandTestBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -445,23 +445,29 @@ protected function mockDownloadCodebaseBackup(object $database, string $url, obj
]) . '.sql.gz';
$localFilepath = Path::join(sys_get_temp_dir(), $filename);

// Cloud API client options are always set first.
$this->clientProphecy->addOption('sink', $localFilepath)->shouldBeCalled();
$this->clientProphecy->addOption('curl.options', [
'CURLOPT_FILE' => $localFilepath,
'CURLOPT_RETURNTRANSFER' => false,
])->shouldBeCalled();

$this->clientProphecy
->addOption('progress', Argument::that(static fn($v) => is_callable($v)))
->shouldBeCalled();
$this->clientProphecy
->addOption('on_stats', Argument::that(static fn($v) => is_callable($v)))
->shouldBeCalled();
// For codebase UUID scenarios, we use httpClient directly, not acquiaCloudClient.
// Ensure clientProphecy doesn't expect any addOption calls for codebase UUID scenarios.
$this->clientProphecy->addOption(Argument::any(), Argument::any())->shouldNotBeCalled();

// 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);

// Mock HEAD request for backup link validation (called before download).
$headResponse = $this->prophet->prophesize(ResponseInterface::class);
$headResponse->getStatusCode()->willReturn(200);
$this->httpClientProphecy
->request(
'HEAD',
$downloadUrl,
Argument::that(function (array $opts): bool {
// Validate that http_errors is set to false for validation.
return isset($opts['http_errors']) && $opts['http_errors'] === false;
})
)
->willReturn($headResponse->reveal())
->shouldBeCalled();

$capturedOpts = null;
$this->httpClientProphecy
Expand Down
133 changes: 133 additions & 0 deletions tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use GuzzleHttp\Client;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Filesystem\Filesystem;

Expand Down Expand Up @@ -637,4 +638,136 @@ public function testPullDatabasesWithCodebaseUuidOnDemand(): void

self::unsetEnvVars(['AH_CODEBASE_UUID']);
}

public function testPullDatabaseWithInvalidBackupLink404(): 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();
$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();

// Mock invalid backup link (404 response).
$backup = EnvironmentTransformer::transformSiteInstanceDatabaseBackup(new SiteInstanceDatabaseBackupResponse($siteInstanceDatabaseBackups->_embedded->items[0]));
$downloadUrl = $backup->links->download->href ?? 'https://example.com/download-backup';

$headResponse = $this->prophet->prophesize(ResponseInterface::class);
$headResponse->getStatusCode()->willReturn(404);
$this->httpClientProphecy
->request('HEAD', $downloadUrl, Argument::type('array'))
->willReturn($headResponse->reveal())
->shouldBeCalled();

// Ensure clientProphecy doesn't expect any addOption calls for codebase UUID scenarios.
$this->clientProphecy->addOption(Argument::any(), Argument::any())->shouldNotBeCalled();

$localMachineHelper = $this->mockLocalMachineHelper();
$this->mockExecuteMySqlConnect($localMachineHelper, true);

$this->expectException(AcquiaCliException::class);
$this->expectExceptionMessage('Database backup link is invalid or unavailable. Please try again or contact support.');

$inputs = self::inputChooseEnvironment();
$this->executeCommand([
'--no-scripts' => true,
], $inputs);

self::unsetEnvVars(['AH_CODEBASE_UUID']);
}

public function testPullDatabaseWithInvalidBackupLink500(): 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();
$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();

// Mock invalid backup link (500 response).
$backup = EnvironmentTransformer::transformSiteInstanceDatabaseBackup(new SiteInstanceDatabaseBackupResponse($siteInstanceDatabaseBackups->_embedded->items[0]));
$downloadUrl = $backup->links->download->href ?? 'https://example.com/download-backup';

$headResponse = $this->prophet->prophesize(ResponseInterface::class);
$headResponse->getStatusCode()->willReturn(500);
$this->httpClientProphecy
->request('HEAD', $downloadUrl, Argument::type('array'))
->willReturn($headResponse->reveal())
->shouldBeCalled();

// Ensure clientProphecy doesn't expect any addOption calls for codebase UUID scenarios.
$this->clientProphecy->addOption(Argument::any(), Argument::any())->shouldNotBeCalled();

$localMachineHelper = $this->mockLocalMachineHelper();
$this->mockExecuteMySqlConnect($localMachineHelper, true);

$this->expectException(AcquiaCliException::class);
$this->expectExceptionMessage('Database backup link is invalid or unavailable. Please try again or contact support.');

$inputs = self::inputChooseEnvironment();
$this->executeCommand([
'--no-scripts' => true,
], $inputs);

self::unsetEnvVars(['AH_CODEBASE_UUID']);
}
}
Loading