diff --git a/src/Presentation/Console/Commands/RegenerateClientSecretCommand.php b/src/Presentation/Console/Commands/RegenerateClientSecretCommand.php new file mode 100644 index 0000000..79a9df4 --- /dev/null +++ b/src/Presentation/Console/Commands/RegenerateClientSecretCommand.php @@ -0,0 +1,178 @@ +mustSuggestArgumentValuesFor('identifier')) { + $clients = $this->clientRepository->findAll(Pagination::ofOffsetAndLimit(0, 100)); + + /** @var list $items */ + $items = array_map( + static fn (Client $client): Suggestion => new Suggestion( + value: $client->identifier->value, + description: sprintf('%s (%s)', $client->name->value, $client->isActive ? 'Active' : 'Inactive'), + ), + $clients->getItems(), + ); + $suggestions->suggestValues($items); + } + } + + public function handle( + RegenerateClientSecret $usecase, + UsecaseExecutor $executor, + ): int { + $identifierString = $this->resolveIdentifier(); + + if ($identifierString === null) { + return self::FAILURE; + } + + try { + $clientIdentifier = $this->parseClientIdentifier($identifierString); + $client = $this->clientRepository->findByIdentifier($clientIdentifier); + + if ($client === null) { + $this->error(sprintf("Client '%s' not found.", $clientIdentifier->value)); + + return self::FAILURE; + } + + if (! $this->confirmRegeneration($client)) { + $this->warn('Secret regeneration cancelled.'); + + return self::SUCCESS; + } + + $input = new Input($client->id); + + /** @var RegenerateClientSecretView $view */ + $view = UsecaseBuilder::using($executor) + ->do($usecase) + ->from($input) + ->with(new RegenerateClientSecretPresenter()) + ->run(); + + $this->displayResult($client, $view); + + return self::SUCCESS; + } catch (ClientNotFoundException | \InvalidArgumentException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + } + + private function resolveIdentifier(): ?string + { + $raw = $this->argument('identifier'); + + if ($raw !== null) { + return (string) $raw; + } + + $clients = $this->clientRepository->findAll(Pagination::ofOffsetAndLimit(0, 50)); + $items = $clients->getItems(); + + if ($items === []) { + $this->error('No clients found in the database.'); + + return null; + } + + $choices = []; + foreach ($items as $client) { + $choices[$client->identifier->value] = sprintf( + '%s (%s) - %s', + $client->name->value, + $client->isActive ? 'Active' : 'Inactive', + $client->identifier->value, + ); + } + + $selectedDisplay = $this->choice('Which client secret would you like to regenerate?', array_values($choices)); + + return (string) array_search($selectedDisplay, $choices, true); + } + + /** + * @throws \InvalidArgumentException + */ + private function parseClientIdentifier(string $value): ClientIdentifier + { + try { + return new ClientIdentifier($value); + } catch (\InvalidArgumentException) { + throw new \InvalidArgumentException("Invalid client identifier '{$value}'. Expected a valid client identifier."); + } + } + + private function confirmRegeneration(Client $client): bool + { + $this->newLine(); + $this->warn(sprintf("Regenerating the secret will immediately invalidate the existing secret for client '%s'.", $client->name->value)); + + return $this->confirm('Do you want to proceed?', false); + } + + private function displayResult(Client $client, RegenerateClientSecretView $view): void + { + $this->newLine(); + $this->info($view->message); + $this->newLine(); + + $this->table( + ['Field', 'Value'], + [ + ['ID', $client->id->value], + ['Name', $client->name->value], + ['Identifier', $view->identifier], + ['New Secret', $view->secret], + ], + ); + + $this->newLine(); + $this->warn('WARNING: Store the new secret securely, it cannot be retrieved later.'); + } +} diff --git a/src/Presentation/Console/Presenters/Clients/RegenerateClientSecretPresenter.php b/src/Presentation/Console/Presenters/Clients/RegenerateClientSecretPresenter.php new file mode 100644 index 0000000..7798dc4 --- /dev/null +++ b/src/Presentation/Console/Presenters/Clients/RegenerateClientSecretPresenter.php @@ -0,0 +1,26 @@ + */ +final readonly class RegenerateClientSecretPresenter implements Presenter +{ + #[\Override] + public function present(UsecaseOutput $output): object + { + Assert::isInstanceOf($output, Output::class); + + return new RegenerateClientSecretView( + identifier: $output->clientIdentifier->value, + secret: $output->newSecret->value, + message: "Secret for client '{$output->clientIdentifier->value}' regenerated successfully.", + ); + } +} diff --git a/src/Presentation/Console/Presenters/Clients/RegenerateClientSecretView.php b/src/Presentation/Console/Presenters/Clients/RegenerateClientSecretView.php new file mode 100644 index 0000000..6930155 --- /dev/null +++ b/src/Presentation/Console/Presenters/Clients/RegenerateClientSecretView.php @@ -0,0 +1,15 @@ +artisan('client:regenerate-secret', ['identifier' => $invalidIdentifier]) + + // THEN + ->expectsOutput("Invalid client identifier '{$invalidIdentifier}'. Expected a valid client identifier.") + ->assertFailed(); + }); + + it('errors when client does not exist', function (): void { + // GIVEN + /** @var \Tests\TestCase $this */ + $nonExistentIdentifier = 'arcadia_id_' . str_repeat('f', 32); + + // WHEN + $this->artisan('client:regenerate-secret', ['identifier' => $nonExistentIdentifier]) + + // THEN + ->expectsOutput("Client '{$nonExistentIdentifier}' not found.") + ->assertFailed(); + }); + + it('regenerates secret when confirmed with argument provided', function (): void { + // GIVEN + /** @var \Tests\TestCase $this */ + $identifier = 'arcadia_id_' . str_repeat('a', 32); + $oldSecret = 'arcadia_sec_' . str_repeat('1', 86); + + $client = ApiClient::query()->create([ + 'id' => (string) Uuid::uuid7(), + 'name' => 'Production Gateway', + 'slug' => 'production-gateway', + 'identifier' => $identifier, + 'secret' => $oldSecret, + 'is_active' => true, + ]); + + // WHEN + $this->artisan('client:regenerate-secret', ['identifier' => $identifier]) + + // THEN + ->expectsConfirmation('Do you want to proceed?', 'yes') + ->expectsOutputToContain("Secret for client '{$identifier}' regenerated successfully.") + ->expectsOutputToContain('Store the new secret securely') + ->assertSuccessful(); + + $client->refresh(); + expect($client->secret->value)->not->toBe($oldSecret) + ->and((string) $client->secret->value)->toStartWith('arcadia_sec_'); + }); + + it('aborts and keeps old secret when confirmation is declined', function (): void { + // GIVEN + /** @var \Tests\TestCase $this */ + $identifier = 'arcadia_id_' . str_repeat('b', 32); + $oldSecret = 'arcadia_sec_' . str_repeat('2', 86); + + $client = ApiClient::query()->create([ + 'id' => (string) Uuid::uuid7(), + 'name' => 'Staging Gateway', + 'slug' => 'staging-gateway', + 'identifier' => $identifier, + 'secret' => $oldSecret, + 'is_active' => true, + ]); + + // WHEN + $this->artisan('client:regenerate-secret', ['identifier' => $identifier]) + + // THEN + ->expectsConfirmation('Do you want to proceed?', 'no') + ->expectsOutput('Secret regeneration cancelled.') + ->assertSuccessful(); + + $client->refresh(); + expect($client->secret->value)->toBe($oldSecret); + }); + + it('interactively prompts to select client when no argument is provided', function (): void { + // GIVEN + /** @var \Tests\TestCase $this */ + ApiClient::query()->delete(); + + $identifier1 = 'arcadia_id_' . str_repeat('c', 32); + $identifier2 = 'arcadia_id_' . str_repeat('d', 32); + $oldSecret2 = 'arcadia_sec_' . str_repeat('3', 86); + + ApiClient::query()->create([ + 'id' => (string) Uuid::uuid7(), + 'name' => 'Client One', + 'slug' => 'client-one', + 'identifier' => $identifier1, + 'secret' => 'arcadia_sec_' . str_repeat('4', 86), + 'is_active' => true, + ]); + + $client2 = ApiClient::query()->create([ + 'id' => (string) Uuid::uuid7(), + 'name' => 'Client Two', + 'slug' => 'client-two', + 'identifier' => $identifier2, + 'secret' => $oldSecret2, + 'is_active' => false, + ]); + + // WHEN + $this->artisan('client:regenerate-secret') + + // THEN + ->expectsChoice( + 'Which client secret would you like to regenerate?', + "Client Two (Inactive) - {$identifier2}", + [ + "Client One (Active) - {$identifier1}", + "Client Two (Inactive) - {$identifier2}", + ], + ) + ->expectsConfirmation('Do you want to proceed?', 'yes') + ->expectsOutputToContain("Secret for client '{$identifier2}' regenerated successfully.") + ->assertSuccessful(); + + $client2->refresh(); + expect($client2->secret->value)->not->toBe($oldSecret2); + }); + + it('outputs an error when no clients exist in database during interactive prompt', function (): void { + // GIVEN + /** @var \Tests\TestCase $this */ + ApiClient::query()->delete(); + + // WHEN + $this->artisan('client:regenerate-secret') + + // THEN + ->expectsOutput('No clients found in the database.') + ->assertFailed(); + }); + + it('provides autocompletion suggestions for identifier', function (): void { + // GIVEN + /** @var \Tests\TestCase $this */ + ApiClient::query()->delete(); + + ApiClient::query()->create([ + 'id' => (string) Uuid::uuid7(), + 'name' => 'Alpha Client', + 'slug' => 'alpha-client', + 'identifier' => 'arcadia_id_' . str_repeat('1', 32), + 'secret' => 'arcadia_sec_' . str_repeat('2', 86), + 'is_active' => true, + ]); + + ApiClient::query()->create([ + 'id' => (string) Uuid::uuid7(), + 'name' => 'Beta Client', + 'slug' => 'beta-client', + 'identifier' => 'arcadia_id_' . str_repeat('3', 32), + 'secret' => 'arcadia_sec_' . str_repeat('4', 86), + 'is_active' => false, + ]); + + /** @var RegenerateClientSecretCommand $command */ + $command = app(RegenerateClientSecretCommand::class); + $tester = new CommandCompletionTester($command); + + // WHEN + $suggestions = $tester->complete(['']); + + // THEN + expect($suggestions) + ->toContain('arcadia_id_' . str_repeat('1', 32)) + ->toContain('arcadia_id_' . str_repeat('3', 32)); + }); +});