feat(console): add RegenerateClientSecret command
This commit is contained in:
parent
d3786aaed6
commit
e3462241d3
4 changed files with 411 additions and 0 deletions
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue