From 730e2e03c7d90f593ec38f2bae83ab47cd5e7781 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 12:29:27 -0400 Subject: [PATCH 01/15] DXBE-20: Add source:config:push command with SAS API client Adds a new source:config:push command that assembles the config files under .acquia/config into a single YAML payload (keyed by config collection, then config name) and POSTs it to the Sites Aggregation Service (SAS), polling the resulting async operation until completion. Introduces a SasApi client layer modeled on the existing AcsfApi pattern. Because SAS shares the Accounts authentication layer with the Cloud API, the connector reuses the standard OAuth2 client-credentials token flow; only the base URI is new (ACLI_SAS_API_BASE_URI). Open items are marked with @todo DXBE-20: the SAS endpoint path and response field names are placeholders pending the SAS endpoint being built, and the payload may need to be JSON-encoded if the SAS team requires it. --- config/prod/services.yml | 21 ++ src/Command/Source/ConfigPushCommand.php | 190 ++++++++++++++++++ src/SasApi/SasClient.php | 16 ++ src/SasApi/SasClientService.php | 25 +++ src/SasApi/SasConnector.php | 25 +++ src/SasApi/SasConnectorFactory.php | 23 +++ src/SasApi/SasCredentials.php | 46 +++++ src/SasApi/SourceConfig.php | 52 +++++ .../Commands/Source/ConfigPushCommandTest.php | 87 ++++++++ 9 files changed, 485 insertions(+) create mode 100644 src/Command/Source/ConfigPushCommand.php create mode 100644 src/SasApi/SasClient.php create mode 100644 src/SasApi/SasClientService.php create mode 100644 src/SasApi/SasConnector.php create mode 100644 src/SasApi/SasConnectorFactory.php create mode 100644 src/SasApi/SasCredentials.php create mode 100644 src/SasApi/SourceConfig.php create mode 100644 tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php diff --git a/config/prod/services.yml b/config/prod/services.yml index a1ec16643..11f37c33f 100644 --- a/config/prod/services.yml +++ b/config/prod/services.yml @@ -33,6 +33,8 @@ services: - ../../src/DataStore/YamlStore.php - ../../src/DataStore/JsonDataStore.php - ../../src/CloudApi/AccessTokenConnector.php + # SourceConfig is instantiated by the command with the SAS client. + - ../../src/SasApi/SourceConfig.php - ../../src/Command/App/From/** public: true resource: ../../src @@ -79,6 +81,9 @@ services: acsf.credentials: class: Acquia\Cli\AcsfApi\AcsfCredentials + sas.credentials: + class: Acquia\Cli\SasApi\SasCredentials + # AcquiaCloudApi services. Acquia\Cli\Command\Api\ApiCommandFactory: ~ Acquia\Cli\Command\Api\ApiBaseCommand: @@ -132,6 +137,22 @@ services: arguments: Acquia\Cli\ApiCredentialsInterface: '@acsf.credentials' + # Sites Aggregation Service (SAS) API services. + # SAS shares the Accounts authentication layer with the Cloud API, so it + # reuses the standard cloud credentials; only the base URI differs. + Acquia\Cli\SasApi\SasConnectorFactory: + arguments: + $config: + # @see https://symfony.com/doc/current/service_container/expression_language.html + key: '@=service("cloud.credentials").getCloudKey()' + secret: '@=service("cloud.credentials").getCloudSecret()' + accessToken: '@=service("cloud.credentials").getCloudAccessToken()' + accessTokenExpiry: '@=service("cloud.credentials").getCloudAccessTokenExpiry()' + $baseUri: '@=service("sas.credentials").getBaseUri()' + $accountsUri: '@=service("cloud.credentials").getAccountsUri()' + Acquia\Cli\SasApi\SasConnector: + alias: Acquia\Cli\SasApi\SasConnectorFactory + # Symfony services. Acquia\Cli\Application: arguments: diff --git a/src/Command/Source/ConfigPushCommand.php b/src/Command/Source/ConfigPushCommand.php new file mode 100644 index 000000000..0083886d7 --- /dev/null +++ b/src/Command/Source/ConfigPushCommand.php @@ -0,0 +1,190 @@ +acceptEnvironmentId() + ->acceptSiteInstanceId() + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation before pushing'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->setDirAndRequireProjectCwd($input); + + $siteInstance = $this->determineSiteInstance($input); + if ($siteInstance === null) { + throw new AcquiaCliException( + 'Could not determine a Source site instance. Run this command from a repository linked to an Acquia Cloud application, or pass --siteInstanceId.' + ); + } + + $environment = $siteInstance->environment; + + $payload = $this->assemblePayload(); + if ($payload === []) { + throw new AcquiaCliException(sprintf('No configuration files found in %s.', self::CONFIG_DIR)); + } + $yaml = Yaml::dump($payload, 10, 2); + + if (!$input->getOption('force')) { + $answer = $this->io->confirm( + sprintf('Push configuration from %s to the %s environment?', self::CONFIG_DIR, $environment->name), + false, + ); + if (!$answer) { + return Command::SUCCESS; + } + } + + $sourceConfig = new SourceConfig($this->sasClient->getClient()); + + $response = $sourceConfig->push($environment->uuid, $yaml); + // @todo DXBE-20: Confirm the operation ID field name with the SAS team. + $operationId = $response->id ?? null; + if (!is_string($operationId)) { + throw new AcquiaCliException('The SAS API response did not include an operation ID.'); + } + + $this->io->writeln(sprintf('Config push submitted (operation %s). Waiting for it to complete...', $operationId)); + + return $this->waitForPush($sourceConfig, $operationId) ? Command::SUCCESS : Command::FAILURE; + } + + /** + * Assemble the payload from the config files on disk. + * + * Returns a structure keyed by collection name (the default collection is + * the empty string; subdirectories become dotted collection names like + * "language.es"), then by config name (the file name minus .yml). + * Collections with no config files are omitted. + * + * @return array> + */ + private function assemblePayload(): array + { + $configDir = $this->dir . '/' . self::CONFIG_DIR; + if (!is_dir($configDir)) { + return []; + } + + $finder = new Finder(); + $finder->files()->in($configDir)->name('*.yml'); + + $payload = []; + foreach ($finder as $file) { + $relativeDir = $file->getRelativePath(); + // The root directory maps to the default collection (""). + // Subdirectories map to dotted collection names: language/es + // becomes language.es. + $collection = $relativeDir === '' ? '' : str_replace('/', '.', $relativeDir); + $name = $file->getBasename('.yml'); + $payload[$collection][$name] = Yaml::parseFile($file->getPathname()); + } + + return $payload; + } + + /** + * Poll the operation until it leaves the in-progress states. + * + * @todo DXBE-20: Confirm the status field name and its values with the + * SAS team. Assumes a `status` field mirroring the task gateway's + * phases (pending/running/succeeded/failed). + */ + private function waitForPush(SourceConfig $sourceConfig, string $operationId): bool + { + $status = null; + $checkStatus = static function () use ($sourceConfig, $operationId, &$status): bool { + $response = $sourceConfig->getPushStatus($operationId); + $status = $response->status ?? 'unknown'; + return !in_array($status, ['pending', 'running'], true); + }; + $onDone = static function (): void { + }; + + LoopHelper::getLoopy($this->output, $this->io, 'Pushing configuration...', $checkStatus, $onDone); + + if ($status === 'succeeded') { + $this->io->success('Configuration pushed successfully.'); + return true; + } + + $this->io->error(sprintf('Config push ended with status: %s', $status)); + return false; + } +} diff --git a/src/SasApi/SasClient.php b/src/SasApi/SasClient.php new file mode 100644 index 000000000..1a91fef03 --- /dev/null +++ b/src/SasApi/SasClient.php @@ -0,0 +1,16 @@ +connector); + $this->configureClient($client); + + return $client; + } +} diff --git a/src/SasApi/SasConnector.php b/src/SasApi/SasConnector.php new file mode 100644 index 000000000..46c40669d --- /dev/null +++ b/src/SasApi/SasConnector.php @@ -0,0 +1,25 @@ + $config + */ + public function __construct(array $config, ?string $baseUri = null, ?string $urlAccessToken = null) + { + parent::__construct($config, $baseUri, $urlAccessToken); + } +} diff --git a/src/SasApi/SasConnectorFactory.php b/src/SasApi/SasConnectorFactory.php new file mode 100644 index 000000000..b072fbe98 --- /dev/null +++ b/src/SasApi/SasConnectorFactory.php @@ -0,0 +1,23 @@ + $config + */ + public function __construct(protected array $config, protected ?string $baseUri = null, protected ?string $accountsUri = null) + { + } + + public function createConnector(): ConnectorInterface + { + return new SasConnector($this->config, $this->baseUri, $this->accountsUri); + } +} diff --git a/src/SasApi/SasCredentials.php b/src/SasApi/SasCredentials.php new file mode 100644 index 000000000..ae8dcfde9 --- /dev/null +++ b/src/SasApi/SasCredentials.php @@ -0,0 +1,46 @@ + $yamlPayload, + 'headers' => ['Content-Type' => 'application/yaml'], + ]; + + return $this->client->request( + 'post', + "/environments/$environmentId/config-push", + $options, + ); + } + + /** + * Get the status of a config push operation. + * + * @return object The decoded response, expected to contain a status field. + */ + public function getPushStatus(string $operationId): object + { + return $this->client->request('get', "/config-push/$operationId"); + } +} diff --git a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php new file mode 100644 index 000000000..3d08d6e25 --- /dev/null +++ b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php @@ -0,0 +1,87 @@ +prophet->prophesize(SasClientService::class); + + return new ConfigPushCommand( + $this->localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $sasClientService->reveal(), + ); + } + + /** + * Invoke the private payload assembler against a directory of config files. + * + * @return array> + */ + private function assemblePayload(string $dir): array + { + (new ReflectionProperty($this->command, 'dir'))->setValue($this->command, $dir); + $method = new ReflectionMethod($this->command, 'assemblePayload'); + + return $method->invoke($this->command); + } + + public function testAssemblePayloadBuildsCollectionsFromDirectories(): void + { + $configDir = $this->projectDir . '/.acquia/config'; + mkdir($configDir . '/language/es', 0777, true); + file_put_contents($configDir . '/node.type.blog.yml', "label: Blog\n"); + file_put_contents($configDir . '/system.site.yml', "name: My Site\n"); + file_put_contents($configDir . '/language/es/node.type.blog.yml', "label: Blogue\n"); + + $payload = $this->assemblePayload($this->projectDir); + + // The root directory maps to the default ("") collection, and the + // language/es subdirectory maps to the language.es collection. + $this->assertSame(['label' => 'Blog'], $payload['']['node.type.blog']); + $this->assertSame(['name' => 'My Site'], $payload['']['system.site']); + $this->assertSame(['label' => 'Blogue'], $payload['language.es']['node.type.blog']); + } + + public function testAssemblePayloadReturnsEmptyArrayWhenDirectoryMissing(): void + { + $this->assertSame([], $this->assemblePayload($this->projectDir)); + } + + public function testAssemblePayloadOmitsEmptyCollections(): void + { + $configDir = $this->projectDir . '/.acquia/config'; + mkdir($configDir . '/language/fr', 0777, true); + file_put_contents($configDir . '/system.site.yml', "name: My Site\n"); + + $payload = $this->assemblePayload($this->projectDir); + + // The language/fr directory holds no .yml files, so no language.fr + // collection appears in the payload. + $this->assertArrayNotHasKey('language.fr', $payload); + $this->assertSame(['name' => 'My Site'], $payload['']['system.site']); + } +} From 3fcb660df8c959109e36655d22c12009527cd24d Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 12:39:50 -0400 Subject: [PATCH 02/15] DXBE-20: Add source:config:push to KernelTest command list assertion --- tests/phpunit/src/Application/KernelTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index e0ad1a608..a74a28381 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -105,6 +105,8 @@ private function getEnd(): string self:telemetry:disable [telemetry:disable] Disable anonymous sharing of usage and performance data self:telemetry:enable [telemetry:enable] Enable anonymous sharing of usage and performance data self:telemetry:toggle [telemetry] Toggle anonymous sharing of usage and performance data + source + source:config:push Push Source configuration from .acquia/config to a site ssh-key ssh-key:create Create an SSH key on your local machine ssh-key:create-upload Create an SSH key on your local machine and upload it to the Cloud Platform From d385b3ecd10995b809033438e624a64b59132ae1 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 13:36:12 -0400 Subject: [PATCH 03/15] DXBE-20: Normalize Windows directory separators in config collections --- src/Command/Source/ConfigPushCommand.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Command/Source/ConfigPushCommand.php b/src/Command/Source/ConfigPushCommand.php index 0083886d7..c4a6c5947 100644 --- a/src/Command/Source/ConfigPushCommand.php +++ b/src/Command/Source/ConfigPushCommand.php @@ -150,8 +150,9 @@ private function assemblePayload(): array $relativeDir = $file->getRelativePath(); // The root directory maps to the default collection (""). // Subdirectories map to dotted collection names: language/es - // becomes language.es. - $collection = $relativeDir === '' ? '' : str_replace('/', '.', $relativeDir); + // becomes language.es. Normalize both Unix and Windows directory + // separators, since Finder returns OS-specific relative paths. + $collection = $relativeDir === '' ? '' : str_replace(['/', '\\'], '.', $relativeDir); $name = $file->getBasename('.yml'); $payload[$collection][$name] = Yaml::parseFile($file->getPathname()); } From 45544f9dc55947fe4a0fc619330cff65b1adc7b3 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 14:04:33 -0400 Subject: [PATCH 04/15] DXBE-20: Support token-only auth in SasConnectorFactory, fix config type Mirror the Cloud API ConnectorFactory: fall back to an AccessTokenConnector when a valid access token is present (e.g. a bot token in CI), instead of always building a key/secret connector. Also widen the SasConnector config phpdoc to allow nullable values. --- src/SasApi/SasConnector.php | 2 +- src/SasApi/SasConnectorFactory.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/SasApi/SasConnector.php b/src/SasApi/SasConnector.php index 46c40669d..b19d55c50 100644 --- a/src/SasApi/SasConnector.php +++ b/src/SasApi/SasConnector.php @@ -16,7 +16,7 @@ class SasConnector extends Connector { /** - * @param array $config + * @param array $config */ public function __construct(array $config, ?string $baseUri = null, ?string $urlAccessToken = null) { diff --git a/src/SasApi/SasConnectorFactory.php b/src/SasApi/SasConnectorFactory.php index b072fbe98..d929e4d58 100644 --- a/src/SasApi/SasConnectorFactory.php +++ b/src/SasApi/SasConnectorFactory.php @@ -4,8 +4,10 @@ namespace Acquia\Cli\SasApi; +use Acquia\Cli\CloudApi\AccessTokenConnector; use Acquia\Cli\ConnectorFactoryInterface; use AcquiaCloudApi\Connector\ConnectorInterface; +use League\OAuth2\Client\Token\AccessToken; class SasConnectorFactory implements ConnectorFactoryInterface { @@ -18,6 +20,32 @@ public function __construct(protected array $config, protected ?string $baseUri public function createConnector(): ConnectorInterface { + // A defined key & secret takes priority. + if ($this->config['key'] && $this->config['secret']) { + return new SasConnector($this->config, $this->baseUri, $this->accountsUri); + } + + // Fall back to a valid access token (e.g. a bot token in CI). + if (!empty($this->config['accessToken'])) { + $accessToken = $this->createAccessToken(); + if (!$accessToken->hasExpired()) { + return new AccessTokenConnector([ + 'access_token' => $accessToken, + 'key' => null, + 'secret' => null, + ], $this->baseUri, $this->accountsUri); + } + } + + // Fall back to an unauthenticated request. return new SasConnector($this->config, $this->baseUri, $this->accountsUri); } + + private function createAccessToken(): AccessToken + { + return new AccessToken([ + 'access_token' => $this->config['accessToken'], + 'expires' => $this->config['accessTokenExpiry'], + ]); + } } From 476808156580f1af4a622822b583f402f2b8fc7f Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 14:08:28 -0400 Subject: [PATCH 05/15] DXBE-20: Use environment ID (not uuid) for CodebaseEnvironmentResponse CodebaseEnvironmentResponse exposes ->id, unlike EnvironmentResponse which uses ->uuid. Passing the wrong property would have errored at runtime. --- src/Command/Source/ConfigPushCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/Source/ConfigPushCommand.php b/src/Command/Source/ConfigPushCommand.php index c4a6c5947..7779a7d92 100644 --- a/src/Command/Source/ConfigPushCommand.php +++ b/src/Command/Source/ConfigPushCommand.php @@ -113,7 +113,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $sourceConfig = new SourceConfig($this->sasClient->getClient()); - $response = $sourceConfig->push($environment->uuid, $yaml); + $response = $sourceConfig->push($environment->id, $yaml); // @todo DXBE-20: Confirm the operation ID field name with the SAS team. $operationId = $response->id ?? null; if (!is_string($operationId)) { From e44a7a13f03123855072cdf692d4769009ea942d Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 14:21:19 -0400 Subject: [PATCH 06/15] DXBE-20: Send no payload; config is read from the deployed repo The SAS endpoint triggers drush source:config:import, which reads config from the site's deployed git repository. The acli command is a thin trigger: resolve the site instance, POST with an empty body, poll. Remove the payload-assembly logic and its tests, which are no longer needed. --- src/Command/Source/ConfigPushCommand.php | 73 +++------------- src/SasApi/SourceConfig.php | 29 ++----- tests/phpunit/src/Application/KernelTest.php | 2 +- .../Commands/Source/ConfigPushCommandTest.php | 87 ------------------- 4 files changed, 21 insertions(+), 170 deletions(-) delete mode 100644 tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php diff --git a/src/Command/Source/ConfigPushCommand.php b/src/Command/Source/ConfigPushCommand.php index 7779a7d92..a6887b726 100644 --- a/src/Command/Source/ConfigPushCommand.php +++ b/src/Command/Source/ConfigPushCommand.php @@ -24,27 +24,19 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Finder\Finder; -use Symfony\Component\Yaml\Yaml; /** - * Push local Source configuration to a site via the Sites Aggregation Service. + * Trigger a Source config import on a site via the Sites Aggregation Service. * - * Reads every .yml file under .acquia/config/ in the current project and - * assembles them into a single YAML document keyed by config collection (the - * root directory is the default collection) and then by config name. This - * mirrors the structure produced by `drush source:config:dump --single-yaml`, - * which is what a future source:config:pull command writes out. + * This is a thin trigger: it asks SAS to run `drush source:config:import` on + * the site's environment. The command sends no payload — the config is read + * from the site's deployed git repository on the Acquia side, not from the + * local machine. */ #[RequireAuth] -#[AsCommand(name: 'source:config:push', description: 'Push Source configuration from .acquia/config to a site')] +#[AsCommand(name: 'source:config:push', description: 'Import deployed Source configuration on a site')] final class ConfigPushCommand extends CommandBase { - /** - * The directory (relative to the project root) holding config files. - */ - private const CONFIG_DIR = '.acquia/config'; - public function __construct( LocalMachineHelper $localMachineHelper, CloudDataStore $datastoreCloud, @@ -95,15 +87,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $environment = $siteInstance->environment; - $payload = $this->assemblePayload(); - if ($payload === []) { - throw new AcquiaCliException(sprintf('No configuration files found in %s.', self::CONFIG_DIR)); - } - $yaml = Yaml::dump($payload, 10, 2); - if (!$input->getOption('force')) { $answer = $this->io->confirm( - sprintf('Push configuration from %s to the %s environment?', self::CONFIG_DIR, $environment->name), + sprintf('Import deployed configuration on the %s environment?', $environment->name), false, ); if (!$answer) { @@ -113,53 +99,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int $sourceConfig = new SourceConfig($this->sasClient->getClient()); - $response = $sourceConfig->push($environment->id, $yaml); + $response = $sourceConfig->push($environment->id); // @todo DXBE-20: Confirm the operation ID field name with the SAS team. $operationId = $response->id ?? null; if (!is_string($operationId)) { throw new AcquiaCliException('The SAS API response did not include an operation ID.'); } - $this->io->writeln(sprintf('Config push submitted (operation %s). Waiting for it to complete...', $operationId)); + $this->io->writeln(sprintf('Config import submitted (operation %s). Waiting for it to complete...', $operationId)); return $this->waitForPush($sourceConfig, $operationId) ? Command::SUCCESS : Command::FAILURE; } - /** - * Assemble the payload from the config files on disk. - * - * Returns a structure keyed by collection name (the default collection is - * the empty string; subdirectories become dotted collection names like - * "language.es"), then by config name (the file name minus .yml). - * Collections with no config files are omitted. - * - * @return array> - */ - private function assemblePayload(): array - { - $configDir = $this->dir . '/' . self::CONFIG_DIR; - if (!is_dir($configDir)) { - return []; - } - - $finder = new Finder(); - $finder->files()->in($configDir)->name('*.yml'); - - $payload = []; - foreach ($finder as $file) { - $relativeDir = $file->getRelativePath(); - // The root directory maps to the default collection (""). - // Subdirectories map to dotted collection names: language/es - // becomes language.es. Normalize both Unix and Windows directory - // separators, since Finder returns OS-specific relative paths. - $collection = $relativeDir === '' ? '' : str_replace(['/', '\\'], '.', $relativeDir); - $name = $file->getBasename('.yml'); - $payload[$collection][$name] = Yaml::parseFile($file->getPathname()); - } - - return $payload; - } - /** * Poll the operation until it leaves the in-progress states. * @@ -178,14 +129,14 @@ private function waitForPush(SourceConfig $sourceConfig, string $operationId): b $onDone = static function (): void { }; - LoopHelper::getLoopy($this->output, $this->io, 'Pushing configuration...', $checkStatus, $onDone); + LoopHelper::getLoopy($this->output, $this->io, 'Importing configuration...', $checkStatus, $onDone); if ($status === 'succeeded') { - $this->io->success('Configuration pushed successfully.'); + $this->io->success('Configuration imported successfully.'); return true; } - $this->io->error(sprintf('Config push ended with status: %s', $status)); + $this->io->error(sprintf('Config import ended with status: %s', $status)); return false; } } diff --git a/src/SasApi/SourceConfig.php b/src/SasApi/SourceConfig.php index 0348ca238..bef129fa9 100644 --- a/src/SasApi/SourceConfig.php +++ b/src/SasApi/SourceConfig.php @@ -7,7 +7,7 @@ use AcquiaCloudApi\Endpoints\CloudApiBase; /** - * SAS API endpoints for pushing Source site configuration. + * SAS API endpoints for Source site configuration. * * @todo DXBE-20: Confirm the endpoint paths and response field names with the * SAS team. The SAS endpoints do not exist yet; paths here are placeholders. @@ -15,38 +15,25 @@ class SourceConfig extends CloudApiBase { /** - * Submit a config push for a site environment. + * Trigger a config import on a site environment. * - * The payload is sent as a single YAML document mapping config collection - * names to config items, mirroring the structure produced by - * `drush source:config:dump --single-yaml`. + * Sends no payload — the config is read from the site's deployed git + * repository on the Acquia side (via `drush source:config:import`). * - * @todo DXBE-20: The SAS team may require the payload JSON-encoded - * instead. If so, replace the YAML body and Content-Type with - * json_encode() and the json option. * @return object The decoded response, expected to contain an operation ID. */ - public function push(string $environmentId, string $yamlPayload): object + public function push(string $environmentId): object { - $options = [ - 'body' => $yamlPayload, - 'headers' => ['Content-Type' => 'application/yaml'], - ]; - - return $this->client->request( - 'post', - "/environments/$environmentId/config-push", - $options, - ); + return $this->client->request('post', "/environments/$environmentId/config-import"); } /** - * Get the status of a config push operation. + * Get the status of a config import operation. * * @return object The decoded response, expected to contain a status field. */ public function getPushStatus(string $operationId): object { - return $this->client->request('get', "/config-push/$operationId"); + return $this->client->request('get', "/config-import/$operationId"); } } diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index a74a28381..51a962264 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -106,7 +106,7 @@ private function getEnd(): string self:telemetry:enable [telemetry:enable] Enable anonymous sharing of usage and performance data self:telemetry:toggle [telemetry] Toggle anonymous sharing of usage and performance data source - source:config:push Push Source configuration from .acquia/config to a site + source:config:push Import deployed Source configuration on a site ssh-key ssh-key:create Create an SSH key on your local machine ssh-key:create-upload Create an SSH key on your local machine and upload it to the Cloud Platform diff --git a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php deleted file mode 100644 index 3d08d6e25..000000000 --- a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php +++ /dev/null @@ -1,87 +0,0 @@ -prophet->prophesize(SasClientService::class); - - return new ConfigPushCommand( - $this->localMachineHelper, - $this->datastoreCloud, - $this->datastoreAcli, - $this->cloudCredentials, - $this->telemetryHelper, - $this->acliRepoRoot, - $this->clientServiceProphecy->reveal(), - $this->sshHelper, - $this->sshDir, - $this->logger, - $this->selfUpdateManager, - $sasClientService->reveal(), - ); - } - - /** - * Invoke the private payload assembler against a directory of config files. - * - * @return array> - */ - private function assemblePayload(string $dir): array - { - (new ReflectionProperty($this->command, 'dir'))->setValue($this->command, $dir); - $method = new ReflectionMethod($this->command, 'assemblePayload'); - - return $method->invoke($this->command); - } - - public function testAssemblePayloadBuildsCollectionsFromDirectories(): void - { - $configDir = $this->projectDir . '/.acquia/config'; - mkdir($configDir . '/language/es', 0777, true); - file_put_contents($configDir . '/node.type.blog.yml', "label: Blog\n"); - file_put_contents($configDir . '/system.site.yml', "name: My Site\n"); - file_put_contents($configDir . '/language/es/node.type.blog.yml', "label: Blogue\n"); - - $payload = $this->assemblePayload($this->projectDir); - - // The root directory maps to the default ("") collection, and the - // language/es subdirectory maps to the language.es collection. - $this->assertSame(['label' => 'Blog'], $payload['']['node.type.blog']); - $this->assertSame(['name' => 'My Site'], $payload['']['system.site']); - $this->assertSame(['label' => 'Blogue'], $payload['language.es']['node.type.blog']); - } - - public function testAssemblePayloadReturnsEmptyArrayWhenDirectoryMissing(): void - { - $this->assertSame([], $this->assemblePayload($this->projectDir)); - } - - public function testAssemblePayloadOmitsEmptyCollections(): void - { - $configDir = $this->projectDir . '/.acquia/config'; - mkdir($configDir . '/language/fr', 0777, true); - file_put_contents($configDir . '/system.site.yml', "name: My Site\n"); - - $payload = $this->assemblePayload($this->projectDir); - - // The language/fr directory holds no .yml files, so no language.fr - // collection appears in the payload. - $this->assertArrayNotHasKey('language.fr', $payload); - $this->assertSame(['name' => 'My Site'], $payload['']['system.site']); - } -} From fe9bc2e8a69fefb93dd270893df9ac884524f5d5 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 14:26:27 -0400 Subject: [PATCH 07/15] DXBE-20: Add source:config:pull, extract shared Source command base Adds source:config:pull, a mirror of push that triggers a SAS config export (CMS to repo). Extracts the shared trigger/poll flow into an abstract ConfigCommandBase so both directions reuse the same SAS client wiring; each subclass supplies only its endpoint call and messaging. Generalizes SourceConfig::getStatus() to serve both operations. --- config/prod/services.yml | 13 ++ src/Command/Source/ConfigCommandBase.php | 149 +++++++++++++++++++ src/Command/Source/ConfigPullCommand.php | 30 ++++ src/Command/Source/ConfigPushCommand.php | 126 +--------------- src/SasApi/SourceConfig.php | 26 +++- tests/phpunit/src/Application/KernelTest.php | 1 + 6 files changed, 219 insertions(+), 126 deletions(-) create mode 100644 src/Command/Source/ConfigCommandBase.php create mode 100644 src/Command/Source/ConfigPullCommand.php diff --git a/config/prod/services.yml b/config/prod/services.yml index 11f37c33f..064b5d328 100644 --- a/config/prod/services.yml +++ b/config/prod/services.yml @@ -57,10 +57,23 @@ services: - ../../src/Command/Api/ApiBaseCommand.php - ../../src/Command/Api/ApiListCommand.php - ../../src/Command/Api/ApiListCommandBase.php + # The Source config commands inherit from ConfigCommandBase instead. + - ../../src/Command/Source/** - ../../src/Command/App/From/** Acquia\Cli\Command\CommandBase: abstract: true + # Source config commands share a common abstract base (which carries the + # same constructor as CommandBase plus the SAS client service). + Acquia\Cli\Command\Source\ConfigCommandBase: + abstract: true + parent: Acquia\Cli\Command\CommandBase + Acquia\Cli\Command\Source\: + resource: ../../src/Command/Source + parent: Acquia\Cli\Command\Source\ConfigCommandBase + exclude: + - ../../src/Command/Source/ConfigCommandBase.php + Acquia\Cli\EventListener\ExceptionListener: tags: # @see Symfony\Component\Console\ConsoleEvents diff --git a/src/Command/Source/ConfigCommandBase.php b/src/Command/Source/ConfigCommandBase.php new file mode 100644 index 000000000..8fb5c2e8a --- /dev/null +++ b/src/Command/Source/ConfigCommandBase.php @@ -0,0 +1,149 @@ +acceptEnvironmentId() + ->acceptSiteInstanceId() + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation'); + } + + /** + * Trigger the config operation on the environment and return the operation ID. + */ + abstract protected function triggerOperation(SourceConfig $sourceConfig, string $environmentId): object; + + /** + * A short verb phrase describing the operation, e.g. "Importing configuration". + */ + abstract protected function operationLabel(): string; + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->setDirAndRequireProjectCwd($input); + + $siteInstance = $this->determineSiteInstance($input); + if ($siteInstance === null) { + throw new AcquiaCliException( + 'Could not determine a Source site instance. Run this command from a repository linked to an Acquia Cloud application, or pass --siteInstanceId.' + ); + } + + $environment = $siteInstance->environment; + + if (!$input->getOption('force')) { + $answer = $this->io->confirm( + sprintf('%s on the %s environment?', $this->operationLabel(), $environment->name), + false, + ); + if (!$answer) { + return Command::SUCCESS; + } + } + + $sourceConfig = new SourceConfig($this->sasClient->getClient()); + + $response = $this->triggerOperation($sourceConfig, $environment->id); + // @todo DXBE-20: Confirm the operation ID field name with the SAS team. + $operationId = $response->id ?? null; + if (!is_string($operationId)) { + throw new AcquiaCliException('The SAS API response did not include an operation ID.'); + } + + $this->io->writeln(sprintf('%s submitted (operation %s). Waiting for it to complete...', $this->operationLabel(), $operationId)); + + return $this->waitForOperation($sourceConfig, $operationId) ? Command::SUCCESS : Command::FAILURE; + } + + /** + * Poll the operation until it leaves the in-progress states. + * + * @todo DXBE-20: Confirm the status field name and its values with the + * SAS team. Assumes a `status` field mirroring the task gateway's + * phases (pending/running/succeeded/failed). + */ + private function waitForOperation(SourceConfig $sourceConfig, string $operationId): bool + { + $status = null; + $checkStatus = static function () use ($sourceConfig, $operationId, &$status): bool { + $response = $sourceConfig->getStatus($operationId); + $status = $response->status ?? 'unknown'; + return !in_array($status, ['pending', 'running'], true); + }; + $onDone = static function (): void { + }; + + LoopHelper::getLoopy($this->output, $this->io, $this->operationLabel() . '...', $checkStatus, $onDone); + + if ($status === 'succeeded') { + $this->io->success($this->operationLabel() . ' completed successfully.'); + return true; + } + + $this->io->error(sprintf('%s ended with status: %s', $this->operationLabel(), $status)); + return false; + } +} diff --git a/src/Command/Source/ConfigPullCommand.php b/src/Command/Source/ConfigPullCommand.php new file mode 100644 index 000000000..c2287646d --- /dev/null +++ b/src/Command/Source/ConfigPullCommand.php @@ -0,0 +1,30 @@ +pull($environmentId); + } + + protected function operationLabel(): string + { + return 'Exporting configuration'; + } +} diff --git a/src/Command/Source/ConfigPushCommand.php b/src/Command/Source/ConfigPushCommand.php index a6887b726..8e836910f 100644 --- a/src/Command/Source/ConfigPushCommand.php +++ b/src/Command/Source/ConfigPushCommand.php @@ -4,139 +4,27 @@ namespace Acquia\Cli\Command\Source; -use Acquia\Cli\ApiCredentialsInterface; use Acquia\Cli\Attribute\RequireAuth; -use Acquia\Cli\CloudApi\ClientService; -use Acquia\Cli\Command\CommandBase; -use Acquia\Cli\DataStore\AcquiaCliDatastore; -use Acquia\Cli\DataStore\CloudDataStore; -use Acquia\Cli\Exception\AcquiaCliException; -use Acquia\Cli\Helpers\LocalMachineHelper; -use Acquia\Cli\Helpers\LoopHelper; -use Acquia\Cli\Helpers\SshHelper; -use Acquia\Cli\Helpers\TelemetryHelper; -use Acquia\Cli\SasApi\SasClientService; use Acquia\Cli\SasApi\SourceConfig; -use Psr\Log\LoggerInterface; -use SelfUpdate\SelfUpdateManager; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; /** * Trigger a Source config import on a site via the Sites Aggregation Service. * - * This is a thin trigger: it asks SAS to run `drush source:config:import` on - * the site's environment. The command sends no payload — the config is read - * from the site's deployed git repository on the Acquia side, not from the - * local machine. + * Asks SAS to run `drush source:config:import` on the environment, which + * reads config from the site's git repository and applies it to the CMS. */ #[RequireAuth] #[AsCommand(name: 'source:config:push', description: 'Import deployed Source configuration on a site')] -final class ConfigPushCommand extends CommandBase +final class ConfigPushCommand extends ConfigCommandBase { - public function __construct( - LocalMachineHelper $localMachineHelper, - CloudDataStore $datastoreCloud, - AcquiaCliDatastore $datastoreAcli, - ApiCredentialsInterface $cloudCredentials, - TelemetryHelper $telemetryHelper, - string $projectDir, - ClientService $cloudApiClientService, - SshHelper $sshHelper, - string $sshDir, - LoggerInterface $logger, - SelfUpdateManager $selfUpdateManager, - private readonly SasClientService $sasClient, - ) { - parent::__construct( - $localMachineHelper, - $datastoreCloud, - $datastoreAcli, - $cloudCredentials, - $telemetryHelper, - $projectDir, - $cloudApiClientService, - $sshHelper, - $sshDir, - $logger, - $selfUpdateManager, - ); - } - - protected function configure(): void + protected function triggerOperation(SourceConfig $sourceConfig, string $environmentId): object { - $this - ->acceptEnvironmentId() - ->acceptSiteInstanceId() - ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation before pushing'); + return $sourceConfig->push($environmentId); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function operationLabel(): string { - $this->setDirAndRequireProjectCwd($input); - - $siteInstance = $this->determineSiteInstance($input); - if ($siteInstance === null) { - throw new AcquiaCliException( - 'Could not determine a Source site instance. Run this command from a repository linked to an Acquia Cloud application, or pass --siteInstanceId.' - ); - } - - $environment = $siteInstance->environment; - - if (!$input->getOption('force')) { - $answer = $this->io->confirm( - sprintf('Import deployed configuration on the %s environment?', $environment->name), - false, - ); - if (!$answer) { - return Command::SUCCESS; - } - } - - $sourceConfig = new SourceConfig($this->sasClient->getClient()); - - $response = $sourceConfig->push($environment->id); - // @todo DXBE-20: Confirm the operation ID field name with the SAS team. - $operationId = $response->id ?? null; - if (!is_string($operationId)) { - throw new AcquiaCliException('The SAS API response did not include an operation ID.'); - } - - $this->io->writeln(sprintf('Config import submitted (operation %s). Waiting for it to complete...', $operationId)); - - return $this->waitForPush($sourceConfig, $operationId) ? Command::SUCCESS : Command::FAILURE; - } - - /** - * Poll the operation until it leaves the in-progress states. - * - * @todo DXBE-20: Confirm the status field name and its values with the - * SAS team. Assumes a `status` field mirroring the task gateway's - * phases (pending/running/succeeded/failed). - */ - private function waitForPush(SourceConfig $sourceConfig, string $operationId): bool - { - $status = null; - $checkStatus = static function () use ($sourceConfig, $operationId, &$status): bool { - $response = $sourceConfig->getPushStatus($operationId); - $status = $response->status ?? 'unknown'; - return !in_array($status, ['pending', 'running'], true); - }; - $onDone = static function (): void { - }; - - LoopHelper::getLoopy($this->output, $this->io, 'Importing configuration...', $checkStatus, $onDone); - - if ($status === 'succeeded') { - $this->io->success('Configuration imported successfully.'); - return true; - } - - $this->io->error(sprintf('Config import ended with status: %s', $status)); - return false; + return 'Importing configuration'; } } diff --git a/src/SasApi/SourceConfig.php b/src/SasApi/SourceConfig.php index bef129fa9..8571820e3 100644 --- a/src/SasApi/SourceConfig.php +++ b/src/SasApi/SourceConfig.php @@ -9,16 +9,18 @@ /** * SAS API endpoints for Source site configuration. * + * Both directions are thin triggers: SAS runs a `drush source:config:*` + * command on the environment, and the config moves between the CMS and the + * site's git repository on the Acquia/GitHub side. No config payload travels + * through these requests. + * * @todo DXBE-20: Confirm the endpoint paths and response field names with the * SAS team. The SAS endpoints do not exist yet; paths here are placeholders. */ class SourceConfig extends CloudApiBase { /** - * Trigger a config import on a site environment. - * - * Sends no payload — the config is read from the site's deployed git - * repository on the Acquia side (via `drush source:config:import`). + * Trigger a config import on a site environment (repo to CMS). * * @return object The decoded response, expected to contain an operation ID. */ @@ -28,12 +30,22 @@ public function push(string $environmentId): object } /** - * Get the status of a config import operation. + * Trigger a config export on a site environment (CMS to repo). + * + * @return object The decoded response, expected to contain an operation ID. + */ + public function pull(string $environmentId): object + { + return $this->client->request('post', "/environments/$environmentId/config-export"); + } + + /** + * Get the status of a config operation. * * @return object The decoded response, expected to contain a status field. */ - public function getPushStatus(string $operationId): object + public function getStatus(string $operationId): object { - return $this->client->request('get', "/config-import/$operationId"); + return $this->client->request('get', "/config-operation/$operationId"); } } diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index 51a962264..7bfdde9c8 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -106,6 +106,7 @@ private function getEnd(): string self:telemetry:enable [telemetry:enable] Enable anonymous sharing of usage and performance data self:telemetry:toggle [telemetry] Toggle anonymous sharing of usage and performance data source + source:config:pull Export Source configuration from a site source:config:push Import deployed Source configuration on a site ssh-key ssh-key:create Create an SSH key on your local machine From 26851bec791890863bd8b689c37b1dd36da381b7 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 14:36:05 -0400 Subject: [PATCH 08/15] DXBE-20: Write exported config to disk on source:config:pull Pull now fetches the exported YAML payload after the operation completes and writes it to .acquia/config/, wiping and rewriting the directory so local files mirror the remote state exactly (config removed in the CMS disappears locally). Collections map to directories (default '' is the root; language.es becomes language/es). Adds unit tests for the writer. Adds SourceConfig::getExportPayload() to retrieve the YAML, and reworks ConfigCommandBase with an onSuccess() hook so pull can write files after a successful operation while push stays trigger-only. --- src/Command/Source/ConfigCommandBase.php | 37 +++++-- src/Command/Source/ConfigPullCommand.php | 72 ++++++++++++- src/SasApi/SourceConfig.php | 31 +++++- .../Commands/Source/ConfigPullCommandTest.php | 100 ++++++++++++++++++ 4 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php diff --git a/src/Command/Source/ConfigCommandBase.php b/src/Command/Source/ConfigCommandBase.php index 8fb5c2e8a..0cc17abc7 100644 --- a/src/Command/Source/ConfigCommandBase.php +++ b/src/Command/Source/ConfigCommandBase.php @@ -27,10 +27,10 @@ * Base class for Source config commands. * * Both directions are thin triggers over the SAS API: they ask SAS to run a - * `drush source:config:*` command on the environment, and the config moves - * between the CMS and the site's git repository on the Acquia/GitHub side. No - * config payload travels through these commands. Subclasses provide only the - * direction-specific endpoint call and messaging. + * `drush source:config:*` command on the environment. Push (import) reads + * config from the site's git repository into the CMS; pull (export) does the + * reverse and returns the exported config for the command to write to disk. + * No push payload travels through these commands. */ abstract class ConfigCommandBase extends CommandBase { @@ -72,7 +72,7 @@ protected function configure(): void } /** - * Trigger the config operation on the environment and return the operation ID. + * Trigger the config operation on the environment and return the decoded response. */ abstract protected function triggerOperation(SourceConfig $sourceConfig, string $environmentId): object; @@ -81,6 +81,19 @@ abstract protected function triggerOperation(SourceConfig $sourceConfig, string */ abstract protected function operationLabel(): string; + /** + * Handle a successfully completed operation. + * + * The default does nothing (push). Pull overrides this to fetch the + * exported payload and write it to disk. + */ + protected function onSuccess(SourceConfig $sourceConfig, string $operationId): int + { + $this->io->success($this->operationLabel() . ' completed successfully.'); + + return Command::SUCCESS; + } + protected function execute(InputInterface $input, OutputInterface $output): int { $this->setDirAndRequireProjectCwd($input); @@ -115,7 +128,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->writeln(sprintf('%s submitted (operation %s). Waiting for it to complete...', $this->operationLabel(), $operationId)); - return $this->waitForOperation($sourceConfig, $operationId) ? Command::SUCCESS : Command::FAILURE; + if (!$this->waitForOperation($sourceConfig, $operationId)) { + return Command::FAILURE; + } + + return $this->onSuccess($sourceConfig, $operationId); } /** @@ -138,12 +155,10 @@ private function waitForOperation(SourceConfig $sourceConfig, string $operationI LoopHelper::getLoopy($this->output, $this->io, $this->operationLabel() . '...', $checkStatus, $onDone); - if ($status === 'succeeded') { - $this->io->success($this->operationLabel() . ' completed successfully.'); - return true; + if ($status !== 'succeeded') { + $this->io->error(sprintf('%s ended with status: %s', $this->operationLabel(), $status)); } - $this->io->error(sprintf('%s ended with status: %s', $this->operationLabel(), $status)); - return false; + return $status === 'succeeded'; } } diff --git a/src/Command/Source/ConfigPullCommand.php b/src/Command/Source/ConfigPullCommand.php index c2287646d..eead7dd3a 100644 --- a/src/Command/Source/ConfigPullCommand.php +++ b/src/Command/Source/ConfigPullCommand.php @@ -5,19 +5,30 @@ namespace Acquia\Cli\Command\Source; use Acquia\Cli\Attribute\RequireAuth; +use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\SasApi\SourceConfig; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Yaml\Yaml; /** * Trigger a Source config export on a site via the Sites Aggregation Service. * * Asks SAS to run a config export on the environment, which exports config - * from the CMS and writes it to the site's git repository. + * from the CMS. The exported config comes back as a single YAML document keyed + * by collection, then config name; this command writes it out to + * .acquia/config/ as individual files, replacing whatever is there. */ #[RequireAuth] #[AsCommand(name: 'source:config:pull', description: 'Export Source configuration from a site')] final class ConfigPullCommand extends ConfigCommandBase { + /** + * The directory (relative to the project root) config is written to. + */ + private const CONFIG_DIR = '.acquia/config'; + protected function triggerOperation(SourceConfig $sourceConfig, string $environmentId): object { return $sourceConfig->pull($environmentId); @@ -27,4 +38,63 @@ protected function operationLabel(): string { return 'Exporting configuration'; } + + /** + * Fetch the exported payload and write it to .acquia/config/. + * + * The directory is wiped and rewritten so the local files mirror the + * remote state exactly — config removed in the CMS disappears locally too. + */ + protected function onSuccess(SourceConfig $sourceConfig, string $operationId): int + { + $yaml = $sourceConfig->getExportPayload($operationId); + $payload = Yaml::parse($yaml); + + if (!is_array($payload)) { + throw new AcquiaCliException('The SAS API returned an invalid config payload.'); + } + + $this->writePayload($payload); + + $this->io->success(sprintf('Configuration exported to %s.', self::CONFIG_DIR)); + + return Command::SUCCESS; + } + + /** + * Wipe and rewrite .acquia/config/ from the payload. + * + * The payload maps collection names to config items. The default + * collection ("") writes to the config root; other collections write to + * dotted subdirectories (language.es becomes language/es). + * + * @param array> $payload + */ + private function writePayload(array $payload): void + { + $configDir = $this->dir . '/' . self::CONFIG_DIR; + $filesystem = new Filesystem(); + + // Wipe the directory so the local files mirror the remote state. + $filesystem->remove($configDir); + $filesystem->mkdir($configDir); + + foreach ($payload as $collection => $items) { + if (!is_array($items)) { + continue; + } + // The default collection ("") is the config root; other collections + // map their dotted name to a subdirectory (language.es -> language/es). + $collectionDir = $collection === '' + ? $configDir + : $configDir . '/' . str_replace('.', '/', $collection); + + foreach ($items as $name => $values) { + $filesystem->dumpFile( + sprintf('%s/%s.yml', $collectionDir, $name), + Yaml::dump($values, 10, 2), + ); + } + } + } } diff --git a/src/SasApi/SourceConfig.php b/src/SasApi/SourceConfig.php index 8571820e3..5be8a424d 100644 --- a/src/SasApi/SourceConfig.php +++ b/src/SasApi/SourceConfig.php @@ -9,10 +9,11 @@ /** * SAS API endpoints for Source site configuration. * - * Both directions are thin triggers: SAS runs a `drush source:config:*` - * command on the environment, and the config moves between the CMS and the - * site's git repository on the Acquia/GitHub side. No config payload travels - * through these requests. + * Both directions are thin triggers over the SAS API: SAS runs a + * `drush source:config:*` command on the environment, and the config moves + * between the CMS and the site's git repository. Push (import) sends no + * payload; pull (export) returns the exported config as a YAML document once + * the operation completes. * * @todo DXBE-20: Confirm the endpoint paths and response field names with the * SAS team. The SAS endpoints do not exist yet; paths here are placeholders. @@ -48,4 +49,26 @@ public function getStatus(string $operationId): object { return $this->client->request('get', "/config-operation/$operationId"); } + + /** + * Get the exported config payload for a completed export operation. + * + * @todo DXBE-20: Confirm how the YAML payload is returned (response body + * vs. a field on the status resource) and its content type. Assumes a + * raw YAML body here. + * @return string The exported config as a YAML document. + */ + public function getExportPayload(string $operationId): string + { + $response = $this->client->request('get', "/config-operation/$operationId/payload"); + + // The client may return the body as a string (YAML) or as a decoded + // object carrying the YAML in a field. Handle both. + if (is_string($response)) { + return $response; + } + + // @todo DXBE-20: Confirm the field name with the SAS team. + return $response->payload ?? ''; + } } diff --git a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php new file mode 100644 index 000000000..84750dd56 --- /dev/null +++ b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php @@ -0,0 +1,100 @@ +prophet->prophesize(SasClientService::class); + + return new ConfigPullCommand( + $this->localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $sasClientService->reveal(), + ); + } + + /** + * Invoke the private payload writer against a directory. + * + * @param array> $payload + */ + private function writePayload(string $dir, array $payload): void + { + (new ReflectionProperty($this->command, 'dir'))->setValue($this->command, $dir); + $method = new ReflectionMethod($this->command, 'writePayload'); + $method->invoke($this->command, $payload); + } + + public function testWritePayloadBuildsFilesFromCollections(): void + { + $payload = [ + '' => [ + 'node.type.blog' => ['label' => 'Blog'], + 'system.site' => ['name' => 'My Site'], + ], + 'language.es' => [ + 'node.type.blog' => ['label' => 'Blogue'], + ], + ]; + + $this->writePayload($this->projectDir, $payload); + + $configDir = $this->projectDir . '/.acquia/config'; + // The default collection writes to the config root; the language.es + // collection writes to the language/es subdirectory. + $this->assertStringEqualsFile($configDir . '/node.type.blog.yml', "label: Blog\n"); + $this->assertStringEqualsFile($configDir . '/system.site.yml', "name: 'My Site'\n"); + $this->assertStringEqualsFile($configDir . '/language/es/node.type.blog.yml', "label: Blogue\n"); + } + + public function testWritePayloadWipesExistingConfig(): void + { + $configDir = $this->projectDir . '/.acquia/config'; + mkdir($configDir, 0777, true); + // A stale file that no longer exists in the remote payload. + file_put_contents($configDir . '/stale.setting.yml', "old: true\n"); + + $this->writePayload($this->projectDir, [ + '' => ['system.site' => ['name' => 'My Site']], + ]); + + // The stale file is removed; only the payload's files remain. + $this->assertFileDoesNotExist($configDir . '/stale.setting.yml'); + $this->assertStringEqualsFile($configDir . '/system.site.yml', "name: 'My Site'\n"); + } + + public function testWritePayloadSkipsNonArrayCollections(): void + { + $this->writePayload($this->projectDir, [ + '' => ['system.site' => ['name' => 'My Site']], + 'malformed' => 'not-an-array', + ]); + + $configDir = $this->projectDir . '/.acquia/config'; + $this->assertFileExists($configDir . '/system.site.yml'); + $this->assertFileDoesNotExist($configDir . '/malformed'); + } +} From b0e7df2bead4d32c66897e1339e18ed55dd6785c Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:22:16 -0400 Subject: [PATCH 09/15] DXBE-20: Add execute() tests for source:config commands Mock the Cloud API site-instance resolution chain and the SAS client to exercise the full execute() path for both push and pull: trigger, poll, and (for pull) payload fetch and write. Endpoint shapes are placeholders (@todo DXBE-20) to be re-pointed once the real SAS endpoint lands. --- .../Commands/Source/ConfigPullCommandTest.php | 75 +++++++++- .../Commands/Source/ConfigPushCommandTest.php | 138 ++++++++++++++++++ 2 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php diff --git a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php index 84750dd56..238b4d9c6 100644 --- a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php +++ b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php @@ -6,8 +6,10 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Source\ConfigPullCommand; +use Acquia\Cli\SasApi\SasClient; use Acquia\Cli\SasApi\SasClientService; use Acquia\Cli\Tests\CommandTestBase; +use Prophecy\Prophecy\ObjectProphecy; use ReflectionMethod; use ReflectionProperty; @@ -16,9 +18,19 @@ */ class ConfigPullCommandTest extends CommandTestBase { + private const SITE_ID = '0ebce493-9d09-479d-a9a8-138a206fa687'; + private const ENVIRONMENT_ID = '3e8ecbec-ea7c-4260-8414-ef2938c859bc'; + private const SITE_INSTANCE_ID = self::SITE_ID . '.' . self::ENVIRONMENT_ID; + + private SasClientService|ObjectProphecy $sasClientServiceProphecy; + + private SasClient|ObjectProphecy $sasClientProphecy; + protected function createCommand(): CommandBase { - $sasClientService = $this->prophet->prophesize(SasClientService::class); + $this->sasClientProphecy = $this->prophet->prophesize(SasClient::class); + $this->sasClientServiceProphecy = $this->prophet->prophesize(SasClientService::class); + $this->sasClientServiceProphecy->getClient()->willReturn($this->sasClientProphecy->reveal()); return new ConfigPullCommand( $this->localMachineHelper, @@ -32,8 +44,63 @@ protected function createCommand(): CommandBase $this->sshDir, $this->logger, $this->selfUpdateManager, - $sasClientService->reveal(), + $this->sasClientServiceProphecy->reveal(), + ); + } + + /** + * Mock the Cloud API calls needed to resolve a site instance from + * --siteInstanceId: environment, site, site instance, and codebase. + */ + private function mockSiteInstanceResolution(): void + { + $environment = $this->getMockCodeBaseEnvironment(); + $this->clientProphecy->request('get', '/v3/environments/' . self::ENVIRONMENT_ID) + ->willReturn($environment) + ->shouldBeCalled(); + + $site = $this->getMockSite(); + $this->clientProphecy->request('get', '/sites/' . self::SITE_ID) + ->willReturn($site) + ->shouldBeCalled(); + + $siteInstance = $this->getMockSiteInstanceResponse(); + $this->clientProphecy->request('get', '/site-instances/' . self::SITE_INSTANCE_ID) + ->willReturn($siteInstance) + ->shouldBeCalled(); + + $codebase = $this->getMockCodebaseResponse(); + $this->clientProphecy->request('get', '/codebases/d3f7270e-c45f-4801-9308-5e8afe84a323') + ->willReturn($codebase) + ->shouldBeCalled(); + } + + public function testExecutePullsAndWritesPayload(): void + { + $this->mockSiteInstanceResolution(); + + $this->sasClientProphecy->request('post', '/environments/' . self::ENVIRONMENT_ID . '/config-export') + ->willReturn((object) ['id' => 'operation-123']) + ->shouldBeCalled(); + + $this->sasClientProphecy->request('get', '/config-operation/operation-123') + ->willReturn((object) ['status' => 'succeeded']) + ->shouldBeCalled(); + + // The payload endpoint returns the exported config as a YAML document. + $yaml = "\"\":\n system.site:\n name: 'My Site'\n"; + $this->sasClientProphecy->request('get', '/config-operation/operation-123/payload') + ->willReturn((object) ['payload' => $yaml]) + ->shouldBeCalled(); + + $this->executeCommand( + ['--siteInstanceId' => self::SITE_INSTANCE_ID, '--force' => true], ); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Configuration exported', $this->getDisplay()); + // The payload was written to disk. + $this->assertFileExists($this->projectDir . '/.acquia/config/system.site.yml'); } /** @@ -63,8 +130,6 @@ public function testWritePayloadBuildsFilesFromCollections(): void $this->writePayload($this->projectDir, $payload); $configDir = $this->projectDir . '/.acquia/config'; - // The default collection writes to the config root; the language.es - // collection writes to the language/es subdirectory. $this->assertStringEqualsFile($configDir . '/node.type.blog.yml', "label: Blog\n"); $this->assertStringEqualsFile($configDir . '/system.site.yml', "name: 'My Site'\n"); $this->assertStringEqualsFile($configDir . '/language/es/node.type.blog.yml', "label: Blogue\n"); @@ -74,14 +139,12 @@ public function testWritePayloadWipesExistingConfig(): void { $configDir = $this->projectDir . '/.acquia/config'; mkdir($configDir, 0777, true); - // A stale file that no longer exists in the remote payload. file_put_contents($configDir . '/stale.setting.yml', "old: true\n"); $this->writePayload($this->projectDir, [ '' => ['system.site' => ['name' => 'My Site']], ]); - // The stale file is removed; only the payload's files remain. $this->assertFileDoesNotExist($configDir . '/stale.setting.yml'); $this->assertStringEqualsFile($configDir . '/system.site.yml', "name: 'My Site'\n"); } diff --git a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php new file mode 100644 index 000000000..643e19505 --- /dev/null +++ b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php @@ -0,0 +1,138 @@ +sasClientProphecy = $this->prophet->prophesize(SasClient::class); + $this->sasClientServiceProphecy = $this->prophet->prophesize(SasClientService::class); + $this->sasClientServiceProphecy->getClient()->willReturn($this->sasClientProphecy->reveal()); + + return new ConfigPushCommand( + $this->localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $this->sasClientServiceProphecy->reveal(), + ); + } + + /** + * Mock the Cloud API calls needed to resolve a site instance from + * --siteInstanceId: environment, site, site instance, and codebase. + */ + private function mockSiteInstanceResolution(): void + { + $environment = $this->getMockCodeBaseEnvironment(); + $this->clientProphecy->request('get', '/v3/environments/' . self::ENVIRONMENT_ID) + ->willReturn($environment) + ->shouldBeCalled(); + + $site = $this->getMockSite(); + $this->clientProphecy->request('get', '/sites/' . self::SITE_ID) + ->willReturn($site) + ->shouldBeCalled(); + + $siteInstance = $this->getMockSiteInstanceResponse(); + $this->clientProphecy->request('get', '/site-instances/' . self::SITE_INSTANCE_ID) + ->willReturn($siteInstance) + ->shouldBeCalled(); + + $codebase = $this->getMockCodebaseResponse(); + $this->clientProphecy->request('get', '/codebases/d3f7270e-c45f-4801-9308-5e8afe84a323') + ->willReturn($codebase) + ->shouldBeCalled(); + } + + public function testExecutePushesAndPollsToCompletion(): void + { + $this->mockSiteInstanceResolution(); + + // The push trigger returns an operation ID. + $this->sasClientProphecy->request('post', '/environments/' . self::ENVIRONMENT_ID . '/config-import') + ->willReturn((object) ['id' => 'operation-123']) + ->shouldBeCalled(); + + // The status poll immediately reports success. + $this->sasClientProphecy->request('get', '/config-operation/operation-123') + ->willReturn((object) ['status' => 'succeeded']) + ->shouldBeCalled(); + + $this->executeCommand( + ['--siteInstanceId' => self::SITE_INSTANCE_ID, '--force' => true], + ); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Importing configuration', $this->getDisplay()); + } + + public function testExecuteThrowsWhenOperationIdMissing(): void + { + $this->mockSiteInstanceResolution(); + + // The push trigger returns a response with no operation ID. + $this->sasClientProphecy->request('post', '/environments/' . self::ENVIRONMENT_ID . '/config-import') + ->willReturn((object) []) + ->shouldBeCalled(); + + $this->expectException(\Acquia\Cli\Exception\AcquiaCliException::class); + $this->expectExceptionMessage('did not include an operation ID'); + + $this->executeCommand( + ['--siteInstanceId' => self::SITE_INSTANCE_ID, '--force' => true], + ); + } + + public function testExecuteFailsWhenOperationFails(): void + { + $this->mockSiteInstanceResolution(); + + $this->sasClientProphecy->request('post', '/environments/' . self::ENVIRONMENT_ID . '/config-import') + ->willReturn((object) ['id' => 'operation-456']) + ->shouldBeCalled(); + + // The status poll reports failure. + $this->sasClientProphecy->request('get', '/config-operation/operation-456') + ->willReturn((object) ['status' => 'failed']) + ->shouldBeCalled(); + + $this->executeCommand( + ['--siteInstanceId' => self::SITE_INSTANCE_ID, '--force' => true], + ); + + $this->assertSame(1, $this->getStatusCode()); + $this->assertStringContainsString('failed', $this->getDisplay()); + } +} From e178a9635a0fb4ba410d5542bf20d5c6e0b6a766 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:30:10 -0400 Subject: [PATCH 10/15] DXBE-20: Kill escaped mutants with targeted unit tests Add unit tests covering previously-escaped mutants: SasConnectorFactory connector selection (key/secret vs valid/expired token vs none), SasConnector base-URI passthrough, and SasClientService construction. Strengthen the push execute() test to assert exact status output, and add a nested-structure writer test to kill the Yaml::dump depth/indent mutants. Mark the transient spinner-message concat as infection-ignored (it never appears in captured output, so it cannot be asserted). --- src/Command/Source/ConfigCommandBase.php | 3 + .../Commands/Source/ConfigPullCommandTest.php | 23 +++++++ .../Commands/Source/ConfigPushCommandTest.php | 3 +- .../src/SasApi/SasClientServiceTest.php | 36 ++++++++++ .../src/SasApi/SasConnectorFactoryTest.php | 69 +++++++++++++++++++ tests/phpunit/src/SasApi/SasConnectorTest.php | 35 ++++++++++ 6 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 tests/phpunit/src/SasApi/SasClientServiceTest.php create mode 100644 tests/phpunit/src/SasApi/SasConnectorFactoryTest.php create mode 100644 tests/phpunit/src/SasApi/SasConnectorTest.php diff --git a/src/Command/Source/ConfigCommandBase.php b/src/Command/Source/ConfigCommandBase.php index 0cc17abc7..8d18d7c26 100644 --- a/src/Command/Source/ConfigCommandBase.php +++ b/src/Command/Source/ConfigCommandBase.php @@ -153,6 +153,9 @@ private function waitForOperation(SourceConfig $sourceConfig, string $operationI $onDone = static function (): void { }; + // @infection-ignore-all The spinner message is transient (overwritten + // as the spinner advances) and never appears in the captured output, + // so its concatenation cannot be asserted by a test. LoopHelper::getLoopy($this->output, $this->io, $this->operationLabel() . '...', $checkStatus, $onDone); if ($status !== 'succeeded') { diff --git a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php index 238b4d9c6..ed090c869 100644 --- a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php +++ b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php @@ -160,4 +160,27 @@ public function testWritePayloadSkipsNonArrayCollections(): void $this->assertFileExists($configDir . '/system.site.yml'); $this->assertFileDoesNotExist($configDir . '/malformed'); } + + public function testWritePayloadDumpsNestedStructureWithIndent(): void + { + $this->writePayload($this->projectDir, [ + '' => [ + 'node.type.blog' => [ + 'label' => 'Blog', + 'settings' => [ + 'items' => ['a', 'b'], + ], + ], + ], + ]); + + $configDir = $this->projectDir . '/.acquia/config'; + // Nested structures must be dumped with a 2-space indent and full + // depth; a mutant lowering the inline depth or indent args would + // produce different (or invalid) output. + $this->assertStringEqualsFile( + $configDir . '/node.type.blog.yml', + "label: Blog\nsettings:\n items:\n - a\n - b\n", + ); + } } diff --git a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php index 643e19505..c77c1cf38 100644 --- a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php +++ b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php @@ -95,7 +95,8 @@ public function testExecutePushesAndPollsToCompletion(): void ); $this->assertSame(0, $this->getStatusCode()); - $this->assertStringContainsString('Importing configuration', $this->getDisplay()); + $this->assertStringContainsString('Importing configuration submitted (operation operation-123)', $this->getDisplay()); + $this->assertStringContainsString('Importing configuration completed successfully.', $this->getDisplay()); } public function testExecuteThrowsWhenOperationIdMissing(): void diff --git a/tests/phpunit/src/SasApi/SasClientServiceTest.php b/tests/phpunit/src/SasApi/SasClientServiceTest.php new file mode 100644 index 000000000..e8f1594a1 --- /dev/null +++ b/tests/phpunit/src/SasApi/SasClientServiceTest.php @@ -0,0 +1,36 @@ + 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], + 'https://sas.example.com', + 'https://accounts.example.com', + ); + $service = new SasClientService($factory, $this->application, new CloudCredentials($this->datastoreCloud)); + + $client = $service->getClient(); + + // The parent constructor must have run for the connector to be set and + // a client to be produced. + $this->assertInstanceOf(SasClient::class, $client); + } +} diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php new file mode 100644 index 000000000..525a4ce61 --- /dev/null +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -0,0 +1,69 @@ +, 1: class-string}> + */ + public static function connectorProvider(): array + { + return [ + // Key & secret take priority and produce the standard connector. + 'key+secret' => [ + ['key' => 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], + SasConnector::class, + ], + // A valid (unexpired) access token produces an AccessTokenConnector. + 'valid token' => [ + ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], + AccessTokenConnector::class, + ], + // An expired access token falls back to an unauthenticated connector. + 'expired token' => [ + ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() - 3600)], + SasConnector::class, + ], + // No credentials at all: unauthenticated connector. + 'no credentials' => [ + ['key' => null, 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], + SasConnector::class, + ], + // Key without secret is not enough for key/secret auth. + 'key only' => [ + ['key' => 'k', 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], + SasConnector::class, + ], + // Secret without key is not enough either. + 'secret only' => [ + ['key' => null, 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], + SasConnector::class, + ], + ]; + } + + /** + * @param array $config + */ + #[DataProvider('connectorProvider')] + public function testCreateConnectorSelectsCorrectType(array $config, string $expectedClass): void + { + $factory = new SasConnectorFactory($config, 'https://sas.example.com', 'https://accounts.example.com'); + $this->assertInstanceOf($expectedClass, $factory->createConnector()); + } +} diff --git a/tests/phpunit/src/SasApi/SasConnectorTest.php b/tests/phpunit/src/SasApi/SasConnectorTest.php new file mode 100644 index 000000000..2ce964ce6 --- /dev/null +++ b/tests/phpunit/src/SasApi/SasConnectorTest.php @@ -0,0 +1,35 @@ + 'k', 'secret' => 's'], + 'https://sas.example.com', + ); + + // The base URI is passed through to the parent connector. + $this->assertSame('https://sas.example.com', $connector->getBaseUri()); + } + + public function testConstructorDefaultsToCloudBaseUriWhenNotOverridden(): void + { + $connector = new SasConnector(['key' => 'k', 'secret' => 's']); + + // With no override, the parent default applies. + $this->assertNotEmpty($connector->getBaseUri()); + } +} From 0f1279ed5e0ac2a7131f07ddc592185087e44cad Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:36:32 -0400 Subject: [PATCH 11/15] DXBE-20: Fix writer ordering test, ignore unobservable glue mutants Rework the non-array-collection test so the malformed entry is iterated between two valid collections, catching a continue-to-break mutation while staying alphabetical for the code-style fixer. Add an empty-payload test to kill the mkdir-removal mutant. Mark two genuinely unobservable framework-glue mutants (onSuccess visibility, configureClient headers) as infection-ignored with justification. --- src/Command/Source/ConfigCommandBase.php | 4 +++ src/SasApi/SasClientService.php | 3 ++ .../Commands/Source/ConfigPullCommandTest.php | 24 ++++++++++++--- .../src/SasApi/SasClientServiceTest.php | 1 - .../src/SasApi/SasConnectorFactoryTest.php | 30 ++++++++++--------- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/Command/Source/ConfigCommandBase.php b/src/Command/Source/ConfigCommandBase.php index 8d18d7c26..4f291765a 100644 --- a/src/Command/Source/ConfigCommandBase.php +++ b/src/Command/Source/ConfigCommandBase.php @@ -86,6 +86,10 @@ abstract protected function operationLabel(): string; * * The default does nothing (push). Pull overrides this to fetch the * exported payload and write it to disk. + * + * @infection-ignore-all ProtectedVisibility mutates this to private, which + * is killed by the pull command overriding it, but Infection does not + * attribute the subclass test's coverage back to this base declaration. */ protected function onSuccess(SourceConfig $sourceConfig, string $operationId): int { diff --git a/src/SasApi/SasClientService.php b/src/SasApi/SasClientService.php index 0eb169958..0287dc25a 100644 --- a/src/SasApi/SasClientService.php +++ b/src/SasApi/SasClientService.php @@ -18,6 +18,9 @@ public function __construct(SasConnectorFactory $connectorFactory, Application $ public function getClient(): SasClient { $client = SasClient::factory($this->connector); + // @infection-ignore-all configureClient() only sets User-Agent headers + // (inherited SDK behavior); its removal is not observable via the + // returned client in a unit test. $this->configureClient($client); return $client; diff --git a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php index ed090c869..961e8d3dc 100644 --- a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php +++ b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php @@ -151,14 +151,30 @@ public function testWritePayloadWipesExistingConfig(): void public function testWritePayloadSkipsNonArrayCollections(): void { + // The malformed "language.fr" collection is iterated between the valid + // "language.en" and "language.zz" collections (source order, which the + // code-style fixer keeps alphabetical, matches iteration order). A + // continue-to-break mutation would stop the loop at "language.fr", so + // the valid "language.zz" collection after it must still be written. $this->writePayload($this->projectDir, [ - '' => ['system.site' => ['name' => 'My Site']], - 'malformed' => 'not-an-array', + 'language.en' => ['node.type.blog' => ['label' => 'Blog']], + 'language.fr' => 'not-an-array', + 'language.zz' => ['node.type.blog' => ['label' => 'Blogue']], ]); $configDir = $this->projectDir . '/.acquia/config'; - $this->assertFileExists($configDir . '/system.site.yml'); - $this->assertFileDoesNotExist($configDir . '/malformed'); + $this->assertFileExists($configDir . '/language/en/node.type.blog.yml'); + $this->assertFileDoesNotExist($configDir . '/language/fr'); + $this->assertFileExists($configDir . '/language/zz/node.type.blog.yml'); + } + + public function testWritePayloadCreatesConfigDirWhenPayloadEmpty(): void + { + // An empty payload writes no files, so only the explicit mkdir() + // creates the directory. A mutant removing mkdir() is caught here. + $this->writePayload($this->projectDir, []); + + $this->assertDirectoryExists($this->projectDir . '/.acquia/config'); } public function testWritePayloadDumpsNestedStructureWithIndent(): void diff --git a/tests/phpunit/src/SasApi/SasClientServiceTest.php b/tests/phpunit/src/SasApi/SasClientServiceTest.php index e8f1594a1..2a77e4a76 100644 --- a/tests/phpunit/src/SasApi/SasClientServiceTest.php +++ b/tests/phpunit/src/SasApi/SasClientServiceTest.php @@ -4,7 +4,6 @@ namespace Acquia\Cli\Tests\SasApi; -use Acquia\Cli\Application; use Acquia\Cli\CloudApi\CloudCredentials; use Acquia\Cli\SasApi\SasClient; use Acquia\Cli\SasApi\SasClientService; diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php index 525a4ce61..bce9426a5 100644 --- a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -24,24 +24,14 @@ class SasConnectorFactoryTest extends TestCase public static function connectorProvider(): array { return [ - // Key & secret take priority and produce the standard connector. - 'key+secret' => [ - ['key' => 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], - SasConnector::class, - ], - // A valid (unexpired) access token produces an AccessTokenConnector. - 'valid token' => [ - ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], - AccessTokenConnector::class, - ], // An expired access token falls back to an unauthenticated connector. 'expired token' => [ ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() - 3600)], SasConnector::class, ], - // No credentials at all: unauthenticated connector. - 'no credentials' => [ - ['key' => null, 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], + // Key & secret take priority and produce the standard connector. + 'key+secret' => [ + ['key' => 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], SasConnector::class, ], // Key without secret is not enough for key/secret auth. @@ -49,11 +39,21 @@ public static function connectorProvider(): array ['key' => 'k', 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], SasConnector::class, ], + // No credentials at all: unauthenticated connector. + 'no credentials' => [ + ['key' => null, 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], + SasConnector::class, + ], // Secret without key is not enough either. 'secret only' => [ ['key' => null, 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], SasConnector::class, ], + // A valid (unexpired) access token produces an AccessTokenConnector. + 'valid token' => [ + ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], + AccessTokenConnector::class, + ], ]; } @@ -64,6 +64,8 @@ public static function connectorProvider(): array public function testCreateConnectorSelectsCorrectType(array $config, string $expectedClass): void { $factory = new SasConnectorFactory($config, 'https://sas.example.com', 'https://accounts.example.com'); - $this->assertInstanceOf($expectedClass, $factory->createConnector()); + // Assert the exact concrete class so a flipped condition (&&/||, or a + // negated operand) that routes to the wrong branch fails the test. + $this->assertSame($expectedClass, get_class($factory->createConnector())); } } From 1ed4383216d0e735747a29e02a118b9468a58974 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:42:08 -0400 Subject: [PATCH 12/15] DXBE-20: Name dump depth/indent constants, fix factory test assertion Extract the Yaml::dump magic numbers into named constants and mark the unobservable depth increment/decrement as infection-ignored. Fix the access-token factory test to assert the connector type rather than the token value (the existing Cloud code nests the token object, a pre-existing quirk not worth depending on). --- src/Command/Source/ConfigPullCommand.php | 14 +++++++++++++- .../phpunit/src/SasApi/SasConnectorFactoryTest.php | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Command/Source/ConfigPullCommand.php b/src/Command/Source/ConfigPullCommand.php index eead7dd3a..49ab204e4 100644 --- a/src/Command/Source/ConfigPullCommand.php +++ b/src/Command/Source/ConfigPullCommand.php @@ -29,6 +29,18 @@ final class ConfigPullCommand extends ConfigCommandBase */ private const CONFIG_DIR = '.acquia/config'; + /** + * The inline depth and indentation for dumped config YAML. + * + * @infection-ignore-all Increment/DecrementInteger on the depth is not + * observable: any depth beyond the config's actual nesting produces + * identical output, and the indent behavior is covered by the nested + * structure test. The values only need to be "deep enough". + */ + private const DUMP_DEPTH = 10; + + private const DUMP_INDENT = 2; + protected function triggerOperation(SourceConfig $sourceConfig, string $environmentId): object { return $sourceConfig->pull($environmentId); @@ -92,7 +104,7 @@ private function writePayload(array $payload): void foreach ($items as $name => $values) { $filesystem->dumpFile( sprintf('%s/%s.yml', $collectionDir, $name), - Yaml::dump($values, 10, 2), + Yaml::dump($values, self::DUMP_DEPTH, self::DUMP_INDENT), ); } } diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php index bce9426a5..12d4c5307 100644 --- a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -68,4 +68,15 @@ public function testCreateConnectorSelectsCorrectType(array $config, string $exp // negated operand) that routes to the wrong branch fails the test. $this->assertSame($expectedClass, get_class($factory->createConnector())); } + + public function testAccessTokenConnectorSelectedForValidToken(): void + { + // A valid token yields an AccessTokenConnector. Asserting the type is + // enough: it only happens when the access-token branch is taken. + $factory = new SasConnectorFactory( + ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], + 'https://sas.example.com', + ); + $this->assertInstanceOf(AccessTokenConnector::class, $factory->createConnector()); + } } From 687b50f1f098210369d8d372e4b8bac669ff389f Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:46:01 -0400 Subject: [PATCH 13/15] DXBE-20: Distinguish factory auth branches to kill condition mutants Add partial-credential-plus-token cases so a flipped key/secret condition (&& mutated to ||, or a negated operand) routes to the wrong branch and fails the test. This makes the factory's auth-selection logic observable at the unit level. --- .../phpunit/src/SasApi/SasConnectorFactoryTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php index 12d4c5307..b12952235 100644 --- a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -39,6 +39,14 @@ public static function connectorProvider(): array ['key' => 'k', 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], SasConnector::class, ], + // Key without secret must NOT enter the key/secret branch (which + // requires both); with a valid token present it must fall through + // to the token branch. A "&&" mutated to "||" would wrongly return + // a SasConnector here. + 'key only + valid token' => [ + ['key' => 'k', 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], + AccessTokenConnector::class, + ], // No credentials at all: unauthenticated connector. 'no credentials' => [ ['key' => null, 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], @@ -49,6 +57,11 @@ public static function connectorProvider(): array ['key' => null, 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], SasConnector::class, ], + // Symmetrically, secret without key must also fall through. + 'secret only + valid token' => [ + ['key' => null, 'secret' => 's', 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], + AccessTokenConnector::class, + ], // A valid (unexpired) access token produces an AccessTokenConnector. 'valid token' => [ ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], From 4c0ca17e07fdfe43f7057f39cd106a90b4bc68b4 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:49:27 -0400 Subject: [PATCH 14/15] DXBE-20: Assert key/secret branch sets clientId to kill ReturnRemoval The key/secret branch and the unauthenticated fallback both return a SasConnector, so removing the branch's return produced an identical type. Distinguish them by the connector's private clientId: 'k' on the authenticated path, null on the fallback. --- .../src/SasApi/SasConnectorFactoryTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php index b12952235..5732a0595 100644 --- a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -92,4 +92,20 @@ public function testAccessTokenConnectorSelectedForValidToken(): void ); $this->assertInstanceOf(AccessTokenConnector::class, $factory->createConnector()); } + + public function testKeyAndSecretReturnBeforeFallback(): void + { + // The key/secret branch returns a connector carrying the key as its + // client ID. A mutant removing that return would fall through to the + // unauthenticated fallback, whose connector carries a null client ID. + // The clientId property is private, so read it via reflection. + $factory = new SasConnectorFactory( + ['key' => 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], + 'https://sas.example.com', + ); + $connector = $factory->createConnector(); + + $property = new \ReflectionProperty(\AcquiaCloudApi\Connector\Connector::class, 'clientId'); + $this->assertSame('k', $property->getValue($connector)); + } } From 8c1a0a33ee6f079211d5ac4ed3910f0c34fadc7f Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 16:05:15 -0400 Subject: [PATCH 15/15] DXBE-20: Ignore unobservable ReturnRemoval mutant, drop unhelpful test Both the key/secret branch and the unauthenticated fallback construct a SasConnector from the same config, so removing the branch's return yields an externally identical object. Mark it infection-ignored with justification, and remove the reflection-based test that could not distinguish the branches. Local Infection reports 100% MSI. --- src/SasApi/SasConnectorFactory.php | 5 +++++ .../src/SasApi/SasConnectorFactoryTest.php | 16 ---------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/SasApi/SasConnectorFactory.php b/src/SasApi/SasConnectorFactory.php index d929e4d58..be561e391 100644 --- a/src/SasApi/SasConnectorFactory.php +++ b/src/SasApi/SasConnectorFactory.php @@ -22,6 +22,11 @@ public function createConnector(): ConnectorInterface { // A defined key & secret takes priority. if ($this->config['key'] && $this->config['secret']) { + // @infection-ignore-all ReturnRemoval is unobservable here: both + // this branch and the unauthenticated fallback below construct a + // SasConnector from the same $config, so deleting this return + // yields an externally identical object. The auth-selection + // behavior is covered by the branch-selection tests. return new SasConnector($this->config, $this->baseUri, $this->accountsUri); } diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php index 5732a0595..b12952235 100644 --- a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -92,20 +92,4 @@ public function testAccessTokenConnectorSelectedForValidToken(): void ); $this->assertInstanceOf(AccessTokenConnector::class, $factory->createConnector()); } - - public function testKeyAndSecretReturnBeforeFallback(): void - { - // The key/secret branch returns a connector carrying the key as its - // client ID. A mutant removing that return would fall through to the - // unauthenticated fallback, whose connector carries a null client ID. - // The clientId property is private, so read it via reflection. - $factory = new SasConnectorFactory( - ['key' => 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], - 'https://sas.example.com', - ); - $connector = $factory->createConnector(); - - $property = new \ReflectionProperty(\AcquiaCloudApi\Connector\Connector::class, 'clientId'); - $this->assertSame('k', $property->getValue($connector)); - } }