feat(console): add RegenerateClientSecret command

This commit is contained in:
Jérémy Munsch 2026-08-18 17:37:44 +02:00
parent d3786aaed6
commit e3462241d3
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
4 changed files with 411 additions and 0 deletions

View file

@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Commands;
use Application\Security\Exceptions\ClientNotFoundException;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Ports\Repositories\ClientRepository;
use Application\Security\Usecases\RegenerateClientSecret\Input;
use Application\Security\Usecases\RegenerateClientSecret\RegenerateClientSecret;
use Illuminate\Console\Command;
use Presentation\Console\Presenters\Clients\RegenerateClientSecretPresenter;
use Presentation\Console\Presenters\Clients\RegenerateClientSecretView;
use Shared\DDD\Usecase\Builder\UsecaseBuilder;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
use Shared\Pagination;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Completion\Suggestion;
final class RegenerateClientSecretCommand extends Command
{
/**
* @var string
*/
protected $signature = 'client:regenerate-secret
{identifier? : The identifier of the client}';
/**
* @var string
*/
protected $description = 'Regenerate the secret of an API client';
public function __construct(
private readonly ClientRepository $clientRepository,
) {
parent::__construct();
}
#[\Override]
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
parent::complete($input, $suggestions);
if ($input->mustSuggestArgumentValuesFor('identifier')) {
$clients = $this->clientRepository->findAll(Pagination::ofOffsetAndLimit(0, 100));
/** @var list<Suggestion> $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.');
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Presenters\Clients;
use Application\Security\Usecases\RegenerateClientSecret\Output;
use Shared\DDD\Usecase\Output as UsecaseOutput;
use Shared\DDD\Usecase\Presenter;
use Webmozart\Assert\Assert;
/** @implements Presenter<Output, RegenerateClientSecretView> */
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.",
);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Presenters\Clients;
final readonly class RegenerateClientSecretView
{
public function __construct(
public string $identifier,
public string $secret,
public string $message,
) {
}
}

View file

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Console\Commands;
use Infra\Database\Models\ApiClient;
use Presentation\Console\Commands\RegenerateClientSecretCommand;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Console\Tester\CommandCompletionTester;
covers(RegenerateClientSecretCommand::class);
describe('RegenerateClientSecretCommand', function (): void {
it('errors when an invalid identifier is provided', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$invalidIdentifier = 'invalid-id';
// WHEN
$this->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));
});
});