diff --git a/database/factories/ApiClientFactory.php b/database/factories/ApiClientFactory.php index d9559ab..f7ddf39 100644 --- a/database/factories/ApiClientFactory.php +++ b/database/factories/ApiClientFactory.php @@ -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, diff --git a/database/migrations/2026_08_10_145347_create_api_clients_table.php b/database/migrations/2026_08_10_145347_create_api_clients_table.php index c29e463..d67931e 100644 --- a/database/migrations/2026_08_10_145347_create_api_clients_table.php +++ b/database/migrations/2026_08_10_145347_create_api_clients_table.php @@ -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'); diff --git a/database/seeders/ApiClientSeeder.php b/database/seeders/ApiClientSeeder.php index 4a7bcda..a6e3107 100644 --- a/database/seeders/ApiClientSeeder.php +++ b/database/seeders/ApiClientSeeder.php @@ -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, ] diff --git a/src/Application/Security/Exceptions/ClientNameAlreadyExistsException.php b/src/Application/Security/Exceptions/ClientNameAlreadyExistsException.php new file mode 100644 index 0000000..008c228 --- /dev/null +++ b/src/Application/Security/Exceptions/ClientNameAlreadyExistsException.php @@ -0,0 +1,13 @@ + */ diff --git a/src/Application/Security/Usecases/CreateClient/CreateClient.php b/src/Application/Security/Usecases/CreateClient/CreateClient.php index a115a3f..2f03d39 100644 --- a/src/Application/Security/Usecases/CreateClient/CreateClient.php +++ b/src/Application/Security/Usecases/CreateClient/CreateClient.php @@ -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(); diff --git a/src/Infra/Adapters/Repositories/ApiClientRepository.php b/src/Infra/Adapters/Repositories/ApiClientRepository.php index df3159d..d71298a 100644 --- a/src/Infra/Adapters/Repositories/ApiClientRepository.php +++ b/src/Infra/Adapters/Repositories/ApiClientRepository.php @@ -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 { diff --git a/src/Infra/Adapters/Repositories/InMemoryClientRepository.php b/src/Infra/Adapters/Repositories/InMemoryClientRepository.php index 7546945..31c8a52 100644 --- a/src/Infra/Adapters/Repositories/InMemoryClientRepository.php +++ b/src/Infra/Adapters/Repositories/InMemoryClientRepository.php @@ -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 { diff --git a/src/Infra/Database/Models/ApiClient.php b/src/Infra/Database/Models/ApiClient.php index 2d55bf8..6edcaea 100644 --- a/src/Infra/Database/Models/ApiClient.php +++ b/src/Infra/Database/Models/ApiClient.php @@ -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', diff --git a/src/Presentation/Console/Commands/CreateClientCommand.php b/src/Presentation/Console/Commands/CreateClientCommand.php index ce6a0fc..407e6e9 100644 --- a/src/Presentation/Console/Commands/CreateClientCommand.php +++ b/src/Presentation/Console/Commands/CreateClientCommand.php @@ -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) { diff --git a/tests/Feature/Api/V1/VerifyHmacSignatureTest.php b/tests/Feature/Api/V1/VerifyHmacSignatureTest.php index 05977a1..a5c7e84 100644 --- a/tests/Feature/Api/V1/VerifyHmacSignatureTest.php +++ b/tests/Feature/Api/V1/VerifyHmacSignatureTest.php @@ -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, diff --git a/tests/Feature/Console/Commands/CreateClientCommandTest.php b/tests/Feature/Console/Commands/CreateClientCommandTest.php index 735e855..98062bf 100644 --- a/tests/Feature/Console/Commands/CreateClientCommandTest.php +++ b/tests/Feature/Console/Commands/CreateClientCommandTest.php @@ -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(); + }); }); diff --git a/tests/Feature/Console/Commands/ListClientsCommandTest.php b/tests/Feature/Console/Commands/ListClientsCommandTest.php index 0c9e1fe..9b1ce58 100644 --- a/tests/Feature/Console/Commands/ListClientsCommandTest.php +++ b/tests/Feature/Console/Commands/ListClientsCommandTest.php @@ -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, diff --git a/tests/Feature/Console/Commands/ToggleClientStatusCommandTest.php b/tests/Feature/Console/Commands/ToggleClientStatusCommandTest.php index 517770c..291904c 100644 --- a/tests/Feature/Console/Commands/ToggleClientStatusCommandTest.php +++ b/tests/Feature/Console/Commands/ToggleClientStatusCommandTest.php @@ -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, diff --git a/tests/Integration/Infra/Adapters/Repositories/ApiClientRepositoryTest.php b/tests/Integration/Infra/Adapters/Repositories/ApiClientRepositoryTest.php index de9280b..d34f018 100644 --- a/tests/Integration/Infra/Adapters/Repositories/ApiClientRepositoryTest.php +++ b/tests/Integration/Infra/Adapters/Repositories/ApiClientRepositoryTest.php @@ -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, diff --git a/tests/Unit/Application/Security/Usecases/CreateClient/CreateClientTest.php b/tests/Unit/Application/Security/Usecases/CreateClient/CreateClientTest.php index 0f671db..b0690e1 100644 --- a/tests/Unit/Application/Security/Usecases/CreateClient/CreateClientTest.php +++ b/tests/Unit/Application/Security/Usecases/CreateClient/CreateClientTest.php @@ -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); + }); });