feat(console): add CreateClient command and enforce unique slug for ApiClient

This commit is contained in:
Jérémy Munsch 2026-08-18 17:31:29 +02:00
parent 0a5603330a
commit d3786aaed6
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
16 changed files with 157 additions and 12 deletions

View file

@ -6,6 +6,7 @@ namespace Database\Factories;
use Application\Security\Ports\Services\ClientCredentialGenerator;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
use Infra\Database\Models\ApiClient;
use Ramsey\Uuid\Uuid;
@ -24,9 +25,12 @@ final class ApiClientFactory extends Factory
{
$clientIdentifierGenerator = resolve(ClientCredentialGenerator::class);
$name = fake()->company();
return [
'id' => Uuid::uuid7()->toString(),
'name' => fake()->company(),
'name' => $name,
'slug' => Str::slug($name),
'identifier' => $clientIdentifierGenerator->generateIdentifier()->value,
'secret' => $clientIdentifierGenerator->generateSecret()->value,
'is_active' => true,

View file

@ -17,6 +17,7 @@ return new class () extends Migration
$table->uuid('id')->primary();
$table->string('name');
$table->string('slug')->unique();
$table->string('identifier')->unique();
$table->text('secret');
$table->boolean('is_active');

View file

@ -6,6 +6,7 @@ namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Infra\Database\Models\ApiClient;
final class ApiClientSeeder extends Seeder
@ -20,6 +21,7 @@ final class ApiClientSeeder extends Seeder
[
'id' => $devClient['id'],
'name' => $devClient['name'],
'slug' => Str::slug($devClient['name']),
'secret' => $devClient['secret'],
'is_active' => true,
]

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Application\Security\Exceptions;
final class ClientNameAlreadyExistsException extends \DomainException
{
public static function forName(string $name): self
{
return new self("A client with the name '{$name}' (or similar) already exists.");
}
}

View file

@ -6,6 +6,7 @@ namespace Application\Security\Ports\Repositories;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Shared\DDD\Port\Repository;
use Shared\Paginated;
@ -22,6 +23,8 @@ interface ClientRepository extends Repository
public function delete(Client $client): void;
public function existsByName(ClientName $name): bool;
public function findByIdentifier(ClientIdentifier $identifier): ?Client;
/** @return Paginated<Client> */

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Application\Security\Usecases\CreateClient;
use Application\Security\Events\ClientSecretGenerated;
use Application\Security\Exceptions\ClientNameAlreadyExistsException;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Application\Security\Ports\Repositories\ClientRepository;
@ -30,6 +31,10 @@ final readonly class CreateClient implements Usecase
{
Assert::isInstanceOf($input, Input::class);
if ($this->clientRepository->existsByName($input->name)) {
throw ClientNameAlreadyExistsException::forName($input->name->value);
}
$clientId = $input->clientId ?? new ClientId(Uuid::uuid7());
$identifier = $this->credentialGenerator->generateIdentifier();
$secret = $this->credentialGenerator->generateSecret();

View file

@ -6,8 +6,10 @@ namespace Infra\Adapters\Repositories;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Application\Security\Ports\Repositories\ClientRepository;
use Illuminate\Support\Str;
use Infra\Database\Models\ApiClient;
use Shared\Paginated;
use Shared\Pagination;
@ -21,6 +23,7 @@ final class ApiClientRepository implements ClientRepository
['id' => $client->id->value],
[
'name' => $client->name,
'slug' => Str::slug($client->name->value),
'identifier' => $client->identifier,
'secret' => $client->secret,
'is_active' => $client->isActive,
@ -28,6 +31,14 @@ final class ApiClientRepository implements ClientRepository
);
}
#[\Override]
public function existsByName(ClientName $name): bool
{
return ApiClient::query()
->where('slug', Str::slug($name->value))
->exists();
}
#[\Override]
public function findByIdentifier(ClientIdentifier $identifier): ?Client
{

View file

@ -6,8 +6,10 @@ namespace Infra\Adapters\Repositories;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Application\Security\Ports\Repositories\ClientRepository;
use Illuminate\Support\Str;
use Shared\Paginated;
use Shared\Pagination;
@ -60,6 +62,20 @@ final class InMemoryClientRepository implements ClientRepository
return null;
}
#[\Override]
public function existsByName(ClientName $name): bool
{
$slug = Str::slug($name->value);
foreach ($this->clients as $client) {
if (Str::slug($client->name->value) === $slug) {
return true;
}
}
return false;
}
#[\Override]
public function findAll(Pagination $pagination): Paginated
{

View file

@ -17,6 +17,7 @@ use Infra\Casts\UuidCast;
/**
* @property \Ramsey\Uuid\UuidInterface $id
* @property \Application\Security\Models\ValueObjects\ClientName $name
* @property string $slug
* @property \Application\Security\Models\ValueObjects\ClientIdentifier $identifier
* @property \Application\Security\Models\ValueObjects\ClientSecret $secret
* @property bool $is_active
@ -49,6 +50,7 @@ final class ApiClient extends Authenticatable
protected $fillable = [
'id',
'name',
'slug',
'identifier',
'secret',
'is_active',

View file

@ -4,7 +4,9 @@ declare(strict_types=1);
namespace Presentation\Console\Commands;
use Application\Security\Exceptions\ClientNameAlreadyExistsException;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Ports\Repositories\ClientRepository;
use Application\Security\Usecases\CreateClient\CreateClient;
use Application\Security\Usecases\CreateClient\Input;
use Illuminate\Console\Command;
@ -25,6 +27,12 @@ final class CreateClientCommand extends Command
*/
protected $description = 'Create a new API client (interactive wizard)';
public function __construct(
private readonly ClientRepository $clientRepository,
) {
parent::__construct();
}
public function handle(
CreateClient $usecase,
UsecaseExecutor $executor,
@ -71,8 +79,14 @@ final class CreateClientCommand extends Command
}
try {
return new ClientName($nameInput);
} catch (\InvalidArgumentException $e) {
$clientName = new ClientName($nameInput);
if ($this->clientRepository->existsByName($clientName)) {
throw ClientNameAlreadyExistsException::forName($clientName->value);
}
return $clientName;
} catch (\InvalidArgumentException | ClientNameAlreadyExistsException $e) {
$this->error($e->getMessage());
if ($attempt < $maxAttempts) {

View file

@ -195,6 +195,7 @@ describe('VerifyHmacSignature Middleware Feature Tests', function (): void {
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Inactive Client',
'slug' => 'test-' . uniqid(),
'identifier' => $identifier,
'secret' => $secret,
'is_active' => false,
@ -230,6 +231,7 @@ describe('VerifyHmacSignature Middleware Feature Tests', function (): void {
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Active Client',
'slug' => 'test-' . uniqid(),
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
@ -267,6 +269,7 @@ describe('VerifyHmacSignature Middleware Feature Tests', function (): void {
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Active Client',
'slug' => 'test-' . uniqid(),
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
@ -303,6 +306,7 @@ describe('VerifyHmacSignature Middleware Feature Tests', function (): void {
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Valid API Client',
'slug' => 'test-' . uniqid(),
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
@ -336,6 +340,7 @@ describe('VerifyHmacSignature Middleware Feature Tests', function (): void {
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Valid API Client',
'slug' => 'test-' . uniqid(),
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,

View file

@ -19,7 +19,7 @@ describe('CreateClientCommand', function (): void {
$this->artisan('client:create')
// THEN
->expectsQuestion('Client name (e.g. "Production Gateway")', $clientName)
->expectsQuestion('Client name (e.g. "Ones Branding website")', $clientName)
->expectsConfirmation('Create this API client?', 'yes')
->expectsOutputToContain("API client '{$clientName}' created successfully.")
->expectsOutputToContain('Store the secret securely')
@ -42,7 +42,7 @@ describe('CreateClientCommand', function (): void {
$this->artisan('client:create')
// THEN
->expectsQuestion('Client name (e.g. "Production Gateway")', $clientName)
->expectsQuestion('Client name (e.g. "Ones Branding website")', $clientName)
->expectsConfirmation('Create this API client?', 'no')
->expectsOutput('Client creation cancelled.')
->assertSuccessful();
@ -55,9 +55,9 @@ describe('CreateClientCommand', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$this->artisan('client:create')
->expectsQuestion('Client name (e.g. "Production Gateway")', '')
->expectsQuestion('Client name (e.g. "Ones Branding website")', '')
->expectsOutput('Client name cannot be empty.')
->expectsQuestion('Client name (e.g. "Production Gateway")', 'Valid Gateway')
->expectsQuestion('Client name (e.g. "Ones Branding website")', 'Valid Gateway')
// WHEN
->expectsConfirmation('Create this API client?', 'yes')
@ -78,15 +78,36 @@ describe('CreateClientCommand', function (): void {
$this->artisan('client:create')
// WHEN
->expectsQuestion('Client name (e.g. "Production Gateway")', '')
->expectsQuestion('Client name (e.g. "Ones Branding website")', '')
->expectsOutput('Client name cannot be empty.')
->expectsQuestion('Client name (e.g. "Production Gateway")', '')
->expectsQuestion('Client name (e.g. "Ones Branding website")', '')
->expectsOutput('Client name cannot be empty.')
->expectsQuestion('Client name (e.g. "Production Gateway")', '')
->expectsQuestion('Client name (e.g. "Ones Branding website")', '')
->expectsOutput('Client name cannot be empty.')
->expectsOutput('Maximum number of attempts reached. Aborting.')
// THEN
->assertFailed();
});
it('retries when the client name already exists', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
ApiClient::factory()->create([
'name' => 'Existing Gateway',
'slug' => 'existing-gateway',
]);
// WHEN
$this->artisan('client:create')
->expectsQuestion('Client name (e.g. "Ones Branding website")', 'Existing Gateway')
->expectsOutput("A client with the name 'Existing Gateway' (or similar) already exists.")
->expectsQuestion('Client name (e.g. "Ones Branding website")', 'New Gateway')
->expectsConfirmation('Create this API client?', 'yes')
// THEN
->expectsOutputToContain("API client 'New Gateway' created successfully.")
->assertSuccessful();
$client = ApiClient::query()->where('name', 'New Gateway')->first();
expect($client)->not->toBeNull();
});
});

View file

@ -31,6 +31,7 @@ describe('ListClientsCommand', function (): void {
$client1 = ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Production Gateway',
'slug' => 'test-' . uniqid(),
'identifier' => 'arcadia_id_' . str_repeat('a', 32),
'secret' => 'arcadia_sec_' . str_repeat('b', 86),
'is_active' => true,
@ -39,6 +40,7 @@ describe('ListClientsCommand', function (): void {
$client2 = ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Backup Gateway',
'slug' => 'test-' . uniqid(),
'identifier' => 'arcadia_id_' . str_repeat('c', 32),
'secret' => 'arcadia_sec_' . str_repeat('d', 86),
'is_active' => false,
@ -79,6 +81,7 @@ describe('ListClientsCommand', function (): void {
ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => "Client {$i}",
'slug' => 'test-' . uniqid(),
'identifier' => 'arcadia_id_' . str_repeat((string) $i, 32),
'secret' => 'arcadia_sec_' . str_repeat((string) $i, 86),
'is_active' => true,

View file

@ -42,6 +42,7 @@ describe('ToggleClientStatusCommand', function (): void {
$client = ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Production Gateway',
'slug' => 'test-' . uniqid(),
'identifier' => 'arcadia_id_' . str_repeat('a', 32),
'secret' => 'arcadia_sec_' . str_repeat('b', 86),
'is_active' => true,
@ -63,6 +64,7 @@ describe('ToggleClientStatusCommand', function (): void {
$client = ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Backup Gateway',
'slug' => 'test-' . uniqid(),
'identifier' => 'arcadia_id_' . str_repeat('c', 32),
'secret' => 'arcadia_sec_' . str_repeat('d', 86),
'is_active' => false,
@ -84,6 +86,7 @@ describe('ToggleClientStatusCommand', function (): void {
$client = ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Special Gateway',
'slug' => 'test-' . uniqid(),
'identifier' => 'arcadia_id_' . str_repeat('e', 32),
'secret' => 'arcadia_sec_' . str_repeat('f', 86),
'is_active' => false,
@ -105,6 +108,7 @@ describe('ToggleClientStatusCommand', function (): void {
$client = ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Special Gateway',
'slug' => 'test-' . uniqid(),
'identifier' => 'arcadia_id_' . str_repeat('g', 32),
'secret' => 'arcadia_sec_' . str_repeat('h', 86),
'is_active' => true,
@ -127,6 +131,7 @@ describe('ToggleClientStatusCommand', function (): void {
ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Some Gateway',
'slug' => 'test-' . uniqid(),
'identifier' => $identifier,
'secret' => 'arcadia_sec_' . str_repeat('z', 86),
'is_active' => true,
@ -147,6 +152,7 @@ describe('ToggleClientStatusCommand', function (): void {
ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Production Gateway',
'slug' => 'test-' . uniqid(),
'identifier' => 'arcadia_id_' . str_repeat('a', 32),
'secret' => 'arcadia_sec_' . str_repeat('b', 86),
'is_active' => true,
@ -155,6 +161,7 @@ describe('ToggleClientStatusCommand', function (): void {
ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Backup Gateway',
'slug' => 'test-' . uniqid(),
'identifier' => 'arcadia_id_' . str_repeat('c', 32),
'secret' => 'arcadia_sec_' . str_repeat('d', 86),
'is_active' => false,

View file

@ -66,6 +66,7 @@ describe('ApiClientRepository Integration Tests', function (): void {
ApiClient::query()->create([
'id' => $rawId,
'name' => 'Initial Client',
'slug' => 'initial-client',
'identifier' => $identifier,
'secret' => $oldSecret,
'is_active' => true,
@ -103,6 +104,7 @@ describe('ApiClientRepository Integration Tests', function (): void {
ApiClient::query()->create([
'id' => $rawId,
'name' => 'Existing Client',
'slug' => 'existing-client',
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
@ -130,6 +132,7 @@ describe('ApiClientRepository Integration Tests', function (): void {
ApiClient::query()->create([
'id' => $rawId,
'name' => 'Searchable Client',
'slug' => 'searchable-client',
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
@ -157,6 +160,7 @@ describe('ApiClientRepository Integration Tests', function (): void {
ApiClient::query()->create([
'id' => $rawId,
'name' => 'Client to Delete',
'slug' => 'client-to-delete',
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
@ -189,6 +193,7 @@ describe('ApiClientRepository Integration Tests', function (): void {
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => "Client $i",
'slug' => "client-$i",
'identifier' => 'arcadia_id_' . str_pad((string) $i, 32, '0', STR_PAD_LEFT),
'secret' => 'arcadia_sec_' . str_pad((string) $i, 86, '0', STR_PAD_LEFT),
'is_active' => true,

View file

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Tests\Unit\Application\Security\Usecases\CreateClient;
use Application\Security\Events\ClientSecretGenerated;
use Application\Security\Exceptions\ClientNameAlreadyExistsException;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\ClientSecret;
@ -117,4 +119,35 @@ describe('CreateClient Usecase Unit Tests', function (): void {
expect($savedClient)->not->toBeNull()
->and($savedClient?->id)->toBe($explicitId);
});
it('throws ClientNameAlreadyExistsException if name already exists', function () use ($repository): void {
// GIVEN
$credentialGenerator = Mockery::mock(ClientCredentialGenerator::class);
$dispatcher = Mockery::mock(DomainEventDispatcher::class);
$usecase = new CreateClient(
$repository,
$credentialGenerator,
$dispatcher
);
$name = new ClientName('Duplicate Gateway');
$input = new Input($name);
$client = Client::hydrate(
id: new ClientId(Uuid::uuid7()),
name: new ClientName('Duplicate Gateway'),
identifier: new ClientIdentifier('arcadia_id_' . str_repeat('e', 32)),
secret: new ClientSecret('arcadia_sec_' . str_repeat('f', 86)),
isActive: true,
createdAt: now()->toImmutable(),
updatedAt: now()->toImmutable(),
);
$repository->persist($client);
// WHEN
expect(fn () => $usecase->execute($input))
// THEN
->toThrow(ClientNameAlreadyExistsException::class);
});
});