diff --git a/src/Application/Security/Events/ClientSecretGenerated.php b/src/Application/Security/Events/ClientSecretGenerated.php new file mode 100644 index 0000000..6c01b8a --- /dev/null +++ b/src/Application/Security/Events/ClientSecretGenerated.php @@ -0,0 +1,19 @@ + + */ +final class Client implements Entity +{ + private function __construct( + public ClientId $id, + public ClientIdentifier $identifier, + public ClientName $name, + public ClientSecret $secret, + public bool $isActive, + public \DateTimeInterface $createdAt, + public \DateTimeInterface $updatedAt, + ) { + } + + #[\Override] + public function id(): ClientId + { + return $this->id; + } + + public static function create( + ClientId $id, + ClientName $name, + ClientIdentifier $identifier, + ClientSecret $secret, + ): self { + $now = new \DateTimeImmutable(); + + return new self( + id: $id, + identifier: $identifier, + name: $name, + secret: $secret, + isActive: true, + createdAt: $now, + updatedAt: $now, + ); + } + + public static function hydrate( + ClientId $id, + ClientName $name, + ClientIdentifier $identifier, + ClientSecret $secret, + bool $isActive, + \DateTimeInterface $createdAt, + \DateTimeInterface $updatedAt, + ): self { + return new self( + id: $id, + identifier: $identifier, + name: $name, + secret: $secret, + isActive: $isActive, + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + } + + public function regenerateSecret(ClientSecret $newSecret): void + { + $this->secret = $newSecret; + $this->updatedAt = new \DateTimeImmutable(); + } + + public function toggleStatus(): bool + { + $this->isActive = ! $this->isActive; + $this->updatedAt = new \DateTimeImmutable(); + + return $this->isActive; + } +} diff --git a/src/Application/Security/Models/ValueObjects/ClientIdentifier.php b/src/Application/Security/Models/ValueObjects/ClientIdentifier.php new file mode 100644 index 0000000..4536bcd --- /dev/null +++ b/src/Application/Security/Models/ValueObjects/ClientIdentifier.php @@ -0,0 +1,40 @@ + 255) { + throw new \InvalidArgumentException('ClientIdentifier cannot exceed 255 characters.'); + } + } + + #[\Override] + public function value(): string + { + return $this->value; + } + + #[\Override] + public function equals(mixed $other): bool + { + return $other instanceof self && $this->value === $other->value; + } +} diff --git a/src/Application/Security/Models/ValueObjects/ClientName.php b/src/Application/Security/Models/ValueObjects/ClientName.php new file mode 100644 index 0000000..7f1627f --- /dev/null +++ b/src/Application/Security/Models/ValueObjects/ClientName.php @@ -0,0 +1,19 @@ + 255) { + throw new \InvalidArgumentException('ClientName cannot exceed 255 characters.'); + } + } +} diff --git a/src/Application/Security/Models/ValueObjects/ClientSecret.php b/src/Application/Security/Models/ValueObjects/ClientSecret.php new file mode 100644 index 0000000..1a03201 --- /dev/null +++ b/src/Application/Security/Models/ValueObjects/ClientSecret.php @@ -0,0 +1,23 @@ + 120) { + throw new \InvalidArgumentException('ClientSecret must be between 86 and 120 characters long.'); + } + } +} diff --git a/src/Application/Security/Models/ValueObjects/Entities/ClientId.php b/src/Application/Security/Models/ValueObjects/Entities/ClientId.php new file mode 100644 index 0000000..a755fe5 --- /dev/null +++ b/src/Application/Security/Models/ValueObjects/Entities/ClientId.php @@ -0,0 +1,22 @@ +toString()); + } + parent::__construct($identifier->toString()); + } +} diff --git a/src/Application/Security/Models/ValueObjects/Request/HttpBodyHash.php b/src/Application/Security/Models/ValueObjects/Request/HttpBodyHash.php new file mode 100644 index 0000000..9ed4c51 --- /dev/null +++ b/src/Application/Security/Models/ValueObjects/Request/HttpBodyHash.php @@ -0,0 +1,21 @@ + + */ +interface ClientRepository extends Repository +{ + public function find(ClientId $id): ?Client; + + public function persist(Client $client): void; + + public function delete(Client $client): void; + + public function findByIdentifier(ClientIdentifier $identifier): ?Client; + + /** @return Paginated */ + public function findAll(Pagination $pagination): Paginated; +} diff --git a/src/Application/Security/Ports/Services/ClientCredentialGenerator.php b/src/Application/Security/Ports/Services/ClientCredentialGenerator.php new file mode 100644 index 0000000..69b2bc1 --- /dev/null +++ b/src/Application/Security/Ports/Services/ClientCredentialGenerator.php @@ -0,0 +1,15 @@ +status === RequestValidationStatus::VALID; + } +} diff --git a/src/Application/Security/Ports/Services/RequestValidation/RequestValidationStatus.php b/src/Application/Security/Ports/Services/RequestValidation/RequestValidationStatus.php new file mode 100644 index 0000000..ea5a392 --- /dev/null +++ b/src/Application/Security/Ports/Services/RequestValidation/RequestValidationStatus.php @@ -0,0 +1,15 @@ + */ +final readonly class CreateClient implements Usecase +{ + public function __construct( + private ClientRepository $clientRepository, + private ClientCredentialGenerator $credentialGenerator, + private DomainEventDispatcher $dispatcher, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $clientId = $input->clientId ?? new ClientId(Uuid::uuid7()); + $identifier = $this->credentialGenerator->generateIdentifier(); + $secret = $this->credentialGenerator->generateSecret(); + + $client = Client::create( + id: $clientId, + name: $input->name, + identifier: $identifier, + secret: $secret, + ); + + $this->clientRepository->persist($client); + + $this->dispatcher->dispatch(new ClientSecretGenerated( + $client->id, + $client->identifier, + new \DateTimeImmutable(), + )); + + return new Output( + clientId: $client->id, + clientIdentifier: $client->identifier, + clientSecret: $client->secret, + clientName: $client->name, + ); + } +} diff --git a/src/Application/Security/Usecases/CreateClient/Input.php b/src/Application/Security/Usecases/CreateClient/Input.php new file mode 100644 index 0000000..b95e342 --- /dev/null +++ b/src/Application/Security/Usecases/CreateClient/Input.php @@ -0,0 +1,17 @@ + */ +final readonly class FindAllClients implements Usecase +{ + public function __construct( + private ClientRepository $repository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $page = $this->repository->findAll($input->pagination); + + return $this->toOutput($page); + } + + /** + * @param Paginated $resultPage + */ + private function toOutput(Paginated $resultPage): Output + { + $pagination = $resultPage->getPagination(); + + return new Output( + clients: array_map( + static fn (Client $client): PaginatedClient => PaginatedClient::fromDomain($client), + $resultPage->getItems() + ), + total: $resultPage->getTotal(), + page: $pagination->pageNumber(), + perPage: $pagination->pageSize(), + ); + } +} diff --git a/src/Application/Security/Usecases/FindAllClients/Input.php b/src/Application/Security/Usecases/FindAllClients/Input.php new file mode 100644 index 0000000..53e8ec2 --- /dev/null +++ b/src/Application/Security/Usecases/FindAllClients/Input.php @@ -0,0 +1,15 @@ + $clients + */ + public function __construct( + public array $clients, + public int $total, + public int $page, + public int $perPage, + ) { + } +} diff --git a/src/Application/Security/Usecases/FindAllClients/PaginatedClient.php b/src/Application/Security/Usecases/FindAllClients/PaginatedClient.php new file mode 100644 index 0000000..5ee173d --- /dev/null +++ b/src/Application/Security/Usecases/FindAllClients/PaginatedClient.php @@ -0,0 +1,36 @@ +id, + identifier: $client->identifier, + name: $client->name, + isActive: $client->isActive, + createdAt: $client->createdAt, + updatedAt: $client->updatedAt, + ); + } +} diff --git a/src/Application/Security/Usecases/RegenerateClientSecret/Input.php b/src/Application/Security/Usecases/RegenerateClientSecret/Input.php new file mode 100644 index 0000000..7421d21 --- /dev/null +++ b/src/Application/Security/Usecases/RegenerateClientSecret/Input.php @@ -0,0 +1,15 @@ + */ +final readonly class RegenerateClientSecret implements Usecase +{ + public function __construct( + private ClientRepository $clientRepository, + private ClientCredentialGenerator $credentialGenerator, + private DomainEventDispatcher $dispatcher, + ) { + } + + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $client = $this->clientRepository->find($input->clientId) ?? throw new ClientNotFoundException(); + + $newSecret = $this->credentialGenerator->generateSecret(); + $client->regenerateSecret($newSecret); + $this->clientRepository->persist($client); + + $this->dispatcher->dispatch(new ClientSecretRegenerated( + $client->id, + $client->identifier, + new \DateTimeImmutable(), + )); + + return new Output($newSecret, $client->identifier); + } +} diff --git a/src/Application/Security/Usecases/ToggleClientStatus/Input.php b/src/Application/Security/Usecases/ToggleClientStatus/Input.php new file mode 100644 index 0000000..8bd5c1f --- /dev/null +++ b/src/Application/Security/Usecases/ToggleClientStatus/Input.php @@ -0,0 +1,15 @@ + */ +final readonly class ToggleClientStatus implements Usecase +{ + public function __construct( + private ClientRepository $clientRepository, + private DomainEventDispatcher $dispatcher, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $client = $this->clientRepository->find($input->clientId) ?? throw new ClientNotFoundException(); + + $isActive = $client->toggleStatus(); + $this->clientRepository->persist($client); + + $this->dispatcher->dispatch(new ClientStatusToggled( + clientId: $client->id, + clientIdentifier: $client->identifier, + isActive: $isActive, + toggledAt: new \DateTimeImmutable(), + )); + + return new Output( + clientId: $client->id, + clientIdentifier: $client->identifier, + clientName: $client->name, + isActive: $isActive, + ); + } +} diff --git a/tests/Unit/Application/Security/Usecases/CreateClient/CreateClientTest.php b/tests/Unit/Application/Security/Usecases/CreateClient/CreateClientTest.php new file mode 100644 index 0000000..0f671db --- /dev/null +++ b/tests/Unit/Application/Security/Usecases/CreateClient/CreateClientTest.php @@ -0,0 +1,120 @@ +clear(); + }); + + it('creates and persists a new client with auto-generated id', function () use ($repository): void { + // GIVEN + $credentialGenerator = Mockery::mock(ClientCredentialGenerator::class); + $dispatcher = Mockery::mock(DomainEventDispatcher::class); + + $usecase = new CreateClient( + $repository, + $credentialGenerator, + $dispatcher + ); + + $name = new ClientName('E-commerce Gateway'); + $input = new Input($name); + $identifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32)); + $secret = new ClientSecret('arcadia_sec_' . str_repeat('b', 86)); + + $credentialGenerator->expects('generateIdentifier') + ->once() + ->andReturn($identifier); + + $credentialGenerator->expects('generateSecret') + ->once() + ->andReturn($secret); + + $dispatcher->expects('dispatch') + ->once() + ->withArgs(fn ($event): bool => $event instanceof ClientSecretGenerated + && $event->clientIdentifier->equals($identifier)); + + // WHEN + $result = $usecase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->clientName)->toBe($name) + ->and($result->clientIdentifier)->toBe($identifier) + ->and($result->clientSecret)->toBe($secret) + ->and($result->clientId)->toBeInstanceOf(ClientId::class); + + $savedClient = $repository->find($result->clientId); + expect($savedClient)->not->toBeNull() + ->and($savedClient?->name)->toBe($name) + ->and($savedClient?->identifier)->toBe($identifier) + ->and($savedClient?->secret)->toBe($secret) + ->and($savedClient?->isActive)->toBeTrue(); + }); + + it('creates and persists a new client with explicit client id', function () use ($repository): void { + // GIVEN + $credentialGenerator = Mockery::mock(ClientCredentialGenerator::class); + $dispatcher = Mockery::mock(DomainEventDispatcher::class); + + $usecase = new CreateClient( + $repository, + $credentialGenerator, + $dispatcher + ); + + $explicitId = new ClientId(Uuid::uuid7()); + $name = new ClientName('Mobile App Client'); + $input = new Input($name, $explicitId); + $identifier = new ClientIdentifier('arcadia_id_' . str_repeat('c', 32)); + $secret = new ClientSecret('arcadia_sec_' . str_repeat('d', 86)); + + $credentialGenerator->allows('generateIdentifier') + ->andReturn($identifier); + + $credentialGenerator->allows('generateSecret') + ->andReturn($secret); + + $dispatcher->allows('dispatch'); + + // WHEN + $result = $usecase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->clientId)->toBe($explicitId) + ->and($result->clientName)->toBe($name) + ->and($result->clientIdentifier)->toBe($identifier) + ->and($result->clientSecret)->toBe($secret); + + $savedClient = $repository->find($explicitId); + expect($savedClient)->not->toBeNull() + ->and($savedClient?->id)->toBe($explicitId); + }); +}); diff --git a/tests/Unit/Application/Security/Usecases/FindAllClients/FindAllClientsTest.php b/tests/Unit/Application/Security/Usecases/FindAllClients/FindAllClientsTest.php new file mode 100644 index 0000000..8808462 --- /dev/null +++ b/tests/Unit/Application/Security/Usecases/FindAllClients/FindAllClientsTest.php @@ -0,0 +1,85 @@ +clear(); + }); + + function createTestClient(string $identifierSuffix, string $nameStr): Client + { + return Client::hydrate( + id: new ClientId(Uuid::uuid7()), + name: new ClientName($nameStr), + identifier: new ClientIdentifier('arcadia_id_' . str_repeat($identifierSuffix, 32)), + secret: new ClientSecret('arcadia_sec_' . str_repeat('a', 86)), + isActive: true, + createdAt: new \DateTimeImmutable('2024-01-01'), + updatedAt: new \DateTimeImmutable('2024-01-01'), + ); + } + + it('retrieves paginated clients list correctly', function () use ($repository): void { + // GIVEN + $client1 = createTestClient('1', 'Client Alpha'); + $client2 = createTestClient('2', 'Client Beta'); + $repository->add($client1, $client2); + + $usecase = new FindAllClients($repository); + $pagination = Pagination::ofPage(1, 10); + $input = new Input($pagination); + + // WHEN + $result = $usecase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->total)->toBe(2) + ->and($result->page)->toBe(1) + ->and($result->perPage)->toBe(10) + ->and($result->clients)->toHaveCount(2) + ->and($result->clients[0])->toBeInstanceOf(PaginatedClient::class) + ->and($result->clients[0]->name->value)->toBe('Client Alpha') + ->and($result->clients[1]->name->value)->toBe('Client Beta'); + }); + + it('returns empty clients list when repository is empty', function () use ($repository): void { + // GIVEN + $usecase = new FindAllClients($repository); + $pagination = Pagination::ofPage(1, 10); + $input = new Input($pagination); + + // WHEN + $result = $usecase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->total)->toBe(0) + ->and($result->clients)->toBeEmpty(); + }); +}); diff --git a/tests/Unit/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecretTest.php b/tests/Unit/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecretTest.php new file mode 100644 index 0000000..9fe511c --- /dev/null +++ b/tests/Unit/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecretTest.php @@ -0,0 +1,99 @@ +clear(); + }); + + it('executes successfully and returns new credentials', function () use ($repository): void { + // GIVEN + $credentialGenerator = Mockery::mock(ClientCredentialGenerator::class); + $dispatcher = Mockery::mock(DomainEventDispatcher::class); + + $usecase = new RegenerateClientSecret( + $repository, + $credentialGenerator, + $dispatcher + ); + + $clientId = new ClientId(Uuid::uuid7()); + $input = new Input($clientId); + $clientIdentifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32)); + $oldSecret = new ClientSecret('arcadia_sec_' . str_repeat('b', 86)); + $newSecret = new ClientSecret('arcadia_sec_' . str_repeat('c', 86)); + + $client = Client::hydrate( + id: $clientId, + name: new ClientName('Test Client'), + identifier: $clientIdentifier, + secret: $oldSecret, + isActive: true, + createdAt: new \DateTimeImmutable(), + updatedAt: new \DateTimeImmutable(), + ); + + $repository->add($client); + + $credentialGenerator->allows('generateSecret') + ->andReturn($newSecret); + + $dispatcher->allows('dispatch'); + + // WHEN + $result = $usecase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->newSecret)->toBe($newSecret) + ->and($result->clientIdentifier)->toBe($clientIdentifier) + ->and($repository->find($clientId)?->secret)->toBe($newSecret); + }); + + it('throws ClientNotFoundException when client does not exist', function () use ($repository): void { + // GIVEN + $credentialGenerator = Mockery::mock(ClientCredentialGenerator::class); + $dispatcher = Mockery::mock(DomainEventDispatcher::class); + + $usecase = new RegenerateClientSecret( + $repository, + $credentialGenerator, + $dispatcher + ); + + $clientId = new ClientId(Uuid::uuid7()); + $input = new Input($clientId); + + // WHEN + $execution = fn () => $usecase->execute($input); + + // THEN + expect($execution)->toThrow(ClientNotFoundException::class); + }); +}); diff --git a/tests/Unit/Application/Security/Usecases/ToggleClientStatus/ToggleClientStatusTest.php b/tests/Unit/Application/Security/Usecases/ToggleClientStatus/ToggleClientStatusTest.php new file mode 100644 index 0000000..e8ff624 --- /dev/null +++ b/tests/Unit/Application/Security/Usecases/ToggleClientStatus/ToggleClientStatusTest.php @@ -0,0 +1,132 @@ +clear(); + }); + + it('toggles an active client to inactive and dispatches event', function () use ($repository): void { + // GIVEN + $dispatcher = Mockery::mock(DomainEventDispatcher::class); + $usecase = new ToggleClientStatus($repository, $dispatcher); + + $clientId = new ClientId(Uuid::uuid7()); + $identifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32)); + $name = new ClientName('Test Gateway'); + $secret = new ClientSecret('arcadia_sec_' . str_repeat('b', 86)); + + $client = Client::hydrate( + id: $clientId, + name: $name, + identifier: $identifier, + secret: $secret, + isActive: true, + createdAt: new \DateTimeImmutable(), + updatedAt: new \DateTimeImmutable(), + ); + + $repository->add($client); + + $dispatcher->expects('dispatch') + ->once() + ->withArgs(fn ($event): bool => $event instanceof ClientStatusToggled + && $event->clientId->equals($clientId) + && $event->isActive === false); + + $input = new Input($clientId); + + // WHEN + $result = $usecase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->clientId)->toBe($clientId) + ->and($result->clientIdentifier)->toBe($identifier) + ->and($result->clientName)->toBe($name) + ->and($result->isActive)->toBeFalse(); + + $saved = $repository->find($clientId); + expect($saved?->isActive)->toBeFalse(); + }); + + it('toggles an inactive client to active and dispatches event', function () use ($repository): void { + // GIVEN + $dispatcher = Mockery::mock(DomainEventDispatcher::class); + $usecase = new ToggleClientStatus($repository, $dispatcher); + + $clientId = new ClientId(Uuid::uuid7()); + $identifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32)); + $name = new ClientName('Inactive Client'); + $secret = new ClientSecret('arcadia_sec_' . str_repeat('b', 86)); + + $client = Client::hydrate( + id: $clientId, + name: $name, + identifier: $identifier, + secret: $secret, + isActive: false, + createdAt: new \DateTimeImmutable(), + updatedAt: new \DateTimeImmutable(), + ); + + $repository->add($client); + + $dispatcher->expects('dispatch') + ->once() + ->withArgs(fn ($event): bool => $event instanceof ClientStatusToggled + && $event->clientId->equals($clientId) + && $event->isActive === true); + + $input = new Input($clientId); + + // WHEN + $result = $usecase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->isActive)->toBeTrue(); + + $saved = $repository->find($clientId); + expect($saved?->isActive)->toBeTrue(); + }); + + it('throws ClientNotFoundException when client does not exist', function () use ($repository): void { + // GIVEN + $dispatcher = Mockery::mock(DomainEventDispatcher::class); + $usecase = new ToggleClientStatus($repository, $dispatcher); + + $clientId = new ClientId(Uuid::uuid7()); + $input = new Input($clientId); + + // WHEN & THEN + expect(fn () => $usecase->execute($input)) + ->toThrow(ClientNotFoundException::class); + }); +}); diff --git a/tests/Unit/Application/Security/ValueObjects/ClientIdentifierTest.php b/tests/Unit/Application/Security/ValueObjects/ClientIdentifierTest.php new file mode 100644 index 0000000..1a7095a --- /dev/null +++ b/tests/Unit/Application/Security/ValueObjects/ClientIdentifierTest.php @@ -0,0 +1,93 @@ +value)->toBe($validClientIdentifier) + ->and($clientIdentifier->value())->toBe($validClientIdentifier); + })->with([ + 'arcadia_id_' . str_repeat('a', 32), + str_repeat('c', 43), + str_repeat('x', 255), + 'client-identifier_with-valid_chars-1234567890123456', + ]); +}); + +describe('ClientIdentifier Value Object Validation Rules', function (): void { + it('throws InvalidArgumentException when given an empty client identifier', function (string $invalid): void { + // GIVEN + $input = $invalid; + + // WHEN + $execution = fn () => new ClientIdentifier($input); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'ClientIdentifier cannot be empty.' + ); + })->with(['', ' ']); + + it('throws InvalidArgumentException when client identifier format contains invalid characters', function (string $invalid): void { + // GIVEN + $input = $invalid; + + // WHEN + $execution = fn () => new ClientIdentifier($input); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'ClientIdentifier can only contain alphanumeric characters, dashes, and underscores.' + ); + })->with([ + 'arcadia_id_with_invalid_char_@_' . str_repeat('a', 20), + 'arcadia_id with spaces in between' . str_repeat('a', 20), + 'arcadia_id!@#$%^&*()_+' . str_repeat('a', 30), + ]); + + it('throws InvalidArgumentException when client identifier is too short', function (string $invalid): void { + // GIVEN + $input = $invalid; + + // WHEN + $execution = fn () => new ClientIdentifier($input); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'ClientIdentifier must be at least 43 characters long.' + ); + })->with([ + str_repeat('a', 42), + 'arcadia_id_short', + ]); + + it('throws InvalidArgumentException when client identifier exceeds 255 characters', function (): void { + // GIVEN + $input = str_repeat('a', 256); + + // WHEN + $execution = fn () => new ClientIdentifier($input); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'ClientIdentifier cannot exceed 255 characters.' + ); + }); +}); diff --git a/tests/Unit/Application/Security/ValueObjects/ClientNameTest.php b/tests/Unit/Application/Security/ValueObjects/ClientNameTest.php new file mode 100644 index 0000000..67fdd64 --- /dev/null +++ b/tests/Unit/Application/Security/ValueObjects/ClientNameTest.php @@ -0,0 +1,56 @@ +value)->toBe($value); + })->with([ + 'Arcadia Test Client', + 'A', + str_repeat('b', 255), + ]); +}); + +describe('ClientName Value Object Validation Rules', function (): void { + it('throws InvalidArgumentException when given an empty client name', function (): void { + // GIVEN + $invalid = ''; + + // WHEN + $execution = fn () => new ClientName($invalid); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'ClientName cannot be empty.' + ); + }); + + it('throws InvalidArgumentException when client name exceeds 255 characters', function (): void { + // GIVEN + $invalid = str_repeat('a', 256); + + // WHEN + $execution = fn () => new ClientName($invalid); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'ClientName cannot exceed 255 characters.' + ); + }); +}); diff --git a/tests/Unit/Application/Security/ValueObjects/ClientSecretTest.php b/tests/Unit/Application/Security/ValueObjects/ClientSecretTest.php new file mode 100644 index 0000000..123fcab --- /dev/null +++ b/tests/Unit/Application/Security/ValueObjects/ClientSecretTest.php @@ -0,0 +1,77 @@ +value)->toBe($value); + })->with([ + 'arcadia_sec_' . str_repeat('b', 86), + str_repeat('s', 86), + str_repeat('s', 98), + str_repeat('s', 120), + ]); +}); + +describe('ClientSecret Value Object Validation Rules', function (): void { + it('throws InvalidArgumentException when given an empty or whitespace-only client secret', function (string $invalid): void { + // GIVEN + $input = $invalid; + + // WHEN + $execution = fn () => new ClientSecret($input); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'ClientSecret cannot be empty.' + ); + })->with(['', ' ']); + + it('throws InvalidArgumentException when client secret contains invalid characters', function (string $invalid): void { + // GIVEN + $input = $invalid; + + // WHEN + $execution = fn () => new ClientSecret($input); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'ClientSecret can only contain alphanumeric characters, dashes, and underscores.' + ); + })->with([ + 'arcadia_sec_invalid_chars_@!' . str_repeat('a', 60), + 'arcadia_sec with spaces in between' . str_repeat('a', 60), + ]); + + it('throws InvalidArgumentException when client secret has an invalid length', function (string $invalid): void { + // GIVEN + $input = $invalid; + + // WHEN + $execution = fn () => new ClientSecret($input); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'ClientSecret must be between 86 and 120 characters long.' + ); + })->with([ + str_repeat('a', 85), + str_repeat('a', 121), + ]); +}); diff --git a/tests/Unit/Application/Security/ValueObjects/Entities/ClientIdTest.php b/tests/Unit/Application/Security/ValueObjects/Entities/ClientIdTest.php new file mode 100644 index 0000000..4e01c89 --- /dev/null +++ b/tests/Unit/Application/Security/ValueObjects/Entities/ClientIdTest.php @@ -0,0 +1,42 @@ +identifier)->toBe($uuid7) + ->and($clientId->value)->toBe($uuid7->toString()); + }); +}); + +describe('ClientId Value Object Validation Rules', function (): void { + it('throws InvalidUuidException when given a non-v7 UUID identifier', function (UuidInterface $nonV7Uuid): void { + // GIVEN + $input = $nonV7Uuid; + + // WHEN + $execution = fn () => new ClientId($input); + + // THEN + expect($execution)->toThrow(InvalidUuidException::class); + })->with([ + fn () => Uuid::uuid4(), + fn () => Uuid::uuid1(), + ]); +}); diff --git a/tests/Unit/Application/Security/ValueObjects/Request/HttpBodyHashTest.php b/tests/Unit/Application/Security/ValueObjects/Request/HttpBodyHashTest.php new file mode 100644 index 0000000..343f008 --- /dev/null +++ b/tests/Unit/Application/Security/ValueObjects/Request/HttpBodyHashTest.php @@ -0,0 +1,63 @@ +value)->toBe($value); + })->with([ + hash('sha256', ''), + hash('sha256', 'payload content'), + str_repeat('a', 64), + str_repeat('F', 64), + ]); +}); + +describe('HttpBodyHash Value Object Validation Rules', function (): void { + it('throws InvalidArgumentException when given an empty body hash', function (): void { + // GIVEN + $invalid = ''; + + // WHEN + $execution = fn () => new HttpBodyHash($invalid); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'HttpBody cannot be empty' + ); + }); + + it('throws InvalidArgumentException when given an invalid SHA256 hash', function (string $invalid): void { + // GIVEN + $input = $invalid; + + // WHEN + $execution = fn () => new HttpBodyHash($input); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'HttpBody must be a valid SHA256 hash' + ); + })->with([ + 'invalid_hash', + '12345', + str_repeat('g', 64), // Invalid hex character + str_repeat('a', 63), // 63 chars (too short) + str_repeat('a', 65), // 65 chars (too long) + ]); +}); diff --git a/tests/Unit/Application/Security/ValueObjects/Request/NonceTest.php b/tests/Unit/Application/Security/ValueObjects/Request/NonceTest.php new file mode 100644 index 0000000..e21586a --- /dev/null +++ b/tests/Unit/Application/Security/ValueObjects/Request/NonceTest.php @@ -0,0 +1,23 @@ +value)->toBe($uuid); + }); +}); diff --git a/tests/Unit/Application/Security/ValueObjects/Request/RequestSignatureTest.php b/tests/Unit/Application/Security/ValueObjects/Request/RequestSignatureTest.php new file mode 100644 index 0000000..9d3515e --- /dev/null +++ b/tests/Unit/Application/Security/ValueObjects/Request/RequestSignatureTest.php @@ -0,0 +1,58 @@ +value)->toBe($validSignature); + }); +}); + +describe('RequestSignature Value Object Validation Rules', function (): void { + it('throws InvalidArgumentException when given an empty signature', function (string $invalid): void { + // GIVEN + $input = $invalid; + + // WHEN + $execution = fn () => new RequestSignature($input); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'RequestSignature cannot be empty.' + ); + })->with(['', ' ']); + + it('throws InvalidArgumentException when signature format is invalid', function (string $invalid): void { + // GIVEN + $input = $invalid; + + // WHEN + $execution = fn () => new RequestSignature($input); + + // THEN + expect($execution)->toThrow( + \InvalidArgumentException::class, + 'RequestSignature must be a valid 64-character hex HMAC-SHA256 string.' + ); + })->with([ + 'invalid_hex', + '123456', + str_repeat('g', 64), // Invalid hex character 'g' + str_repeat('a', 63), // 63 chars (too short) + str_repeat('a', 65), // 65 chars (too long) + ]); +}); diff --git a/tests/Unit/Application/Security/ValueObjects/Request/RequestTimestampTest.php b/tests/Unit/Application/Security/ValueObjects/Request/RequestTimestampTest.php new file mode 100644 index 0000000..9172346 --- /dev/null +++ b/tests/Unit/Application/Security/ValueObjects/Request/RequestTimestampTest.php @@ -0,0 +1,22 @@ +value)->toBe($dateTime); + }); +});