diff --git a/config/prod/services.yml b/config/prod/services.yml index a1ec16643..064b5d328 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 @@ -55,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 @@ -79,6 +94,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 +150,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/ConfigCommandBase.php b/src/Command/Source/ConfigCommandBase.php new file mode 100644 index 000000000..4f291765a --- /dev/null +++ b/src/Command/Source/ConfigCommandBase.php @@ -0,0 +1,171 @@ +acceptEnvironmentId() + ->acceptSiteInstanceId() + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation'); + } + + /** + * Trigger the config operation on the environment and return the decoded response. + */ + 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; + + /** + * Handle a successfully completed operation. + * + * 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 + { + $this->io->success($this->operationLabel() . ' completed successfully.'); + + return Command::SUCCESS; + } + + 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)); + + if (!$this->waitForOperation($sourceConfig, $operationId)) { + return Command::FAILURE; + } + + return $this->onSuccess($sourceConfig, $operationId); + } + + /** + * 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 { + }; + + // @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') { + $this->io->error(sprintf('%s ended with status: %s', $this->operationLabel(), $status)); + } + + return $status === 'succeeded'; + } +} diff --git a/src/Command/Source/ConfigPullCommand.php b/src/Command/Source/ConfigPullCommand.php new file mode 100644 index 000000000..49ab204e4 --- /dev/null +++ b/src/Command/Source/ConfigPullCommand.php @@ -0,0 +1,112 @@ +pull($environmentId); + } + + 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, self::DUMP_DEPTH, self::DUMP_INDENT), + ); + } + } + } +} diff --git a/src/Command/Source/ConfigPushCommand.php b/src/Command/Source/ConfigPushCommand.php new file mode 100644 index 000000000..8e836910f --- /dev/null +++ b/src/Command/Source/ConfigPushCommand.php @@ -0,0 +1,30 @@ +push($environmentId); + } + + protected function operationLabel(): string + { + return 'Importing configuration'; + } +} 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); + // @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/src/SasApi/SasConnector.php b/src/SasApi/SasConnector.php new file mode 100644 index 000000000..b19d55c50 --- /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..be561e391 --- /dev/null +++ b/src/SasApi/SasConnectorFactory.php @@ -0,0 +1,56 @@ + $config + */ + public function __construct(protected array $config, protected ?string $baseUri = null, protected ?string $accountsUri = null) + { + } + + 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); + } + + // 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'], + ]); + } +} 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 @@ +client->request('post', "/environments/$environmentId/config-import"); + } + + /** + * 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 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/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index e0ad1a608..7bfdde9c8 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -105,6 +105,9 @@ 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: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 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/ConfigPullCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php new file mode 100644 index 000000000..961e8d3dc --- /dev/null +++ b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php @@ -0,0 +1,202 @@ +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, + $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 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'); + } + + /** + * 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'; + $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); + file_put_contents($configDir . '/stale.setting.yml', "old: true\n"); + + $this->writePayload($this->projectDir, [ + '' => ['system.site' => ['name' => 'My Site']], + ]); + + $this->assertFileDoesNotExist($configDir . '/stale.setting.yml'); + $this->assertStringEqualsFile($configDir . '/system.site.yml', "name: 'My Site'\n"); + } + + 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, [ + '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 . '/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 + { + $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 new file mode 100644 index 000000000..c77c1cf38 --- /dev/null +++ b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php @@ -0,0 +1,139 @@ +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 submitted (operation operation-123)', $this->getDisplay()); + $this->assertStringContainsString('Importing configuration completed successfully.', $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()); + } +} diff --git a/tests/phpunit/src/SasApi/SasClientServiceTest.php b/tests/phpunit/src/SasApi/SasClientServiceTest.php new file mode 100644 index 000000000..2a77e4a76 --- /dev/null +++ b/tests/phpunit/src/SasApi/SasClientServiceTest.php @@ -0,0 +1,35 @@ + '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..b12952235 --- /dev/null +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -0,0 +1,95 @@ +, 1: class-string}> + */ + public static function connectorProvider(): array + { + return [ + // An expired access token falls back to an unauthenticated connector. + 'expired token' => [ + ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() - 3600)], + SasConnector::class, + ], + // 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. + 'key only' => [ + ['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], + SasConnector::class, + ], + // Secret without key is not enough either. + 'secret only' => [ + ['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)], + AccessTokenConnector::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'); + // 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())); + } + + 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()); + } +} 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()); + } +}