feat(security): implement security bounded context entities, value objects, ports, use cases and unit tests

This commit is contained in:
Jérémy Munsch 2026-08-18 12:03:25 +02:00
parent 909f7abaa4
commit fa86b9c4e6
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
49 changed files with 1777 additions and 0 deletions

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Application\Security\Events;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Shared\DDD\Event\Event;
final readonly class ClientSecretGenerated implements Event
{
public function __construct(
public ClientId $clientId,
public ClientIdentifier $clientIdentifier,
public \DateTimeImmutable $generatedAt,
) {
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Application\Security\Events;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Shared\DDD\Event\Event;
final readonly class ClientSecretRegenerated implements Event
{
public function __construct(
public ClientId $clientId,
public ClientIdentifier $clientIdentifier,
public \DateTimeImmutable $generatedAt,
) {
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Application\Security\Events;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Shared\DDD\Event\Event;
final readonly class ClientStatusToggled implements Event
{
public function __construct(
public ClientId $clientId,
public ClientIdentifier $clientIdentifier,
public bool $isActive,
public \DateTimeImmutable $toggledAt,
) {
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Application\Security\Exceptions;
use Shared\DDD\Exception\SecurityException;
final class AuthenticationException extends SecurityException
{
public function __construct()
{
parent::__construct('Authentication failed.');
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Application\Security\Exceptions;
use Shared\DDD\Exception\NotFoundException;
final class ClientNotFoundException extends NotFoundException
{
public static function forClient(string $clientId): self
{
return new self("Client '{$clientId}' not found.");
}
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\Entities;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Shared\DDD\Model\Entity;
/**
* @implements Entity<ClientId>
*/
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;
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects;
use Shared\DDD\Security\UserIdentifier;
final readonly class ClientIdentifier implements UserIdentifier
{
public function __construct(
#[\SensitiveParameter]
public string $value,
) {
if (empty(trim($value))) {
throw new \InvalidArgumentException('ClientIdentifier cannot be empty.');
}
if (! preg_match('/^[a-zA-Z0-9_-]+$/', $value)) {
throw new \InvalidArgumentException('ClientIdentifier can only contain alphanumeric characters, dashes, and underscores.');
}
if (strlen($value) < 43) {
throw new \InvalidArgumentException('ClientIdentifier must be at least 43 characters long.');
}
if (strlen($value) > 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;
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects;
final readonly class ClientName
{
public function __construct(
public readonly string $value,
) {
if ($value === '') {
throw new \InvalidArgumentException('ClientName cannot be empty.');
}
if (strlen($value) > 255) {
throw new \InvalidArgumentException('ClientName cannot exceed 255 characters.');
}
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects;
final readonly class ClientSecret
{
public function __construct(
#[\SensitiveParameter]
public string $value,
) {
if (empty(trim($value))) {
throw new \InvalidArgumentException('ClientSecret cannot be empty.');
}
if (! preg_match('/^[a-zA-Z0-9_-]+$/', $value)) {
throw new \InvalidArgumentException('ClientSecret can only contain alphanumeric characters, dashes, and underscores.');
}
if (strlen($value) < 86 || strlen($value) > 120) {
throw new \InvalidArgumentException('ClientSecret must be between 86 and 120 characters long.');
}
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects\Entities;
use Ramsey\Uuid\UuidInterface;
use Shared\DDD\Model\EntityId;
use Shared\Helpers\Exceptions\InvalidUuidException;
use Shared\Helpers\UuidHelper;
final readonly class ClientId extends EntityId
{
public function __construct(
public readonly UuidInterface $identifier,
) {
if (UuidHelper::isV7($identifier) === false) {
throw InvalidUuidException::forValue($identifier->toString());
}
parent::__construct($identifier->toString());
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects\Request;
final readonly class HttpBodyHash
{
public function __construct(
#[\SensitiveParameter]
public string $value
) {
if ($value === '') {
throw new \InvalidArgumentException('HttpBody cannot be empty');
}
// NOTE: Check if the body is a valid SHA256 hash (64 hex characters).
if (! preg_match('/^[a-f0-9]{64}$/i', $value)) {
throw new \InvalidArgumentException('HttpBody must be a valid SHA256 hash');
}
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects\Request;
enum HttpMethod: string
{
case GET = 'GET';
case POST = 'POST';
case PUT = 'PUT';
case DELETE = 'DELETE';
case PATCH = 'PATCH';
case HEAD = 'HEAD';
case OPTIONS = 'OPTIONS';
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects\Request;
use Ramsey\Uuid\UuidInterface;
use Shared\Helpers\UuidHelper;
final readonly class Nonce
{
public function __construct(
public readonly UuidInterface $value,
) {
UuidHelper::validateUuid($value);
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects\Request;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Uri\Rfc3986\Uri;
final readonly class Request
{
public function __construct(
public readonly Uri $uri,
public readonly HttpMethod $method,
public readonly ?HttpBodyHash $bodyHash,
public readonly RequestSignature $signature,
public readonly Nonce $nonce,
public readonly ClientIdentifier $identifier,
public readonly RequestTimestamp $timestamp
) {
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects\Request;
final readonly class RequestSignature
{
public function __construct(
#[\SensitiveParameter]
public readonly string $value,
) {
if (empty(trim($value))) {
throw new \InvalidArgumentException('RequestSignature cannot be empty.');
}
if (! preg_match('/^[0-9a-fA-F]{64}$/', $value)) {
throw new \InvalidArgumentException('RequestSignature must be a valid 64-character hex HMAC-SHA256 string.');
}
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects\Request;
final readonly class RequestTimestamp
{
public function __construct(
public \DateTimeImmutable $value
) {
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Application\Security\Ports\Repositories;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Shared\DDD\Port\Repository;
use Shared\Paginated;
use Shared\Pagination;
/**
* @extends Repository<ClientId, Client>
*/
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<Client> */
public function findAll(Pagination $pagination): Paginated;
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Application\Security\Ports\Services;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientSecret;
interface ClientCredentialGenerator
{
public function generateIdentifier(): ClientIdentifier;
public function generateSecret(): ClientSecret;
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Application\Security\Ports\Services;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Request\Request;
interface HmacSignatureValidator
{
public function validate(Request $request, ClientSecret $withSecret): bool;
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Application\Security\Ports\Services;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Request\Nonce;
interface NonceValidator
{
public function validateAndConsume(Nonce $nonce, ClientIdentifier $forClientIdentifier): bool;
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Application\Security\Ports\Services\RequestValidation;
use Application\Security\Models\Entities\Client;
final readonly class RequestValidationResult
{
private function __construct(
public RequestValidationStatus $status,
public ?Client $client = null,
) {
}
public static function valid(Client $client): self
{
return new self(RequestValidationStatus::VALID, $client);
}
public static function failed(RequestValidationStatus $reason): self
{
return new self($reason);
}
public function isValid(): bool
{
return $this->status === RequestValidationStatus::VALID;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Application\Security\Ports\Services\RequestValidation;
enum RequestValidationStatus
{
case VALID;
case CLIENT_NOT_AUTHORIZED;
case CLIENT_NOT_FOUND;
case INVALID_TIMESTAMP;
case INVALID_NONCE;
case INVALID_SIGNATURE;
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Application\Security\Ports\Services;
use Application\Security\Models\ValueObjects\Request\Request;
use Application\Security\Ports\Services\RequestValidation\RequestValidationResult;
interface RequestValidator
{
public function validate(Request $request): RequestValidationResult;
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Application\Security\Ports\Services;
interface TimestampValidator
{
public const int ALLOWED_TIMESTAMP_SKEW_SECONDS = 300; // 5 minutes
public function validate(\DateTimeInterface $timestamp): bool;
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\CreateClient;
use Application\Security\Events\ClientSecretGenerated;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Application\Security\Ports\Repositories\ClientRepository;
use Application\Security\Ports\Services\ClientCredentialGenerator;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Usecase;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\CreateClient\Input, \Application\Security\Usecases\CreateClient\Output> */
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,
);
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\CreateClient;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\Entities\ClientId;
final readonly class Input implements \Shared\DDD\Usecase\Input
{
public function __construct(
public ClientName $name,
public ?ClientId $clientId = null,
) {
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\CreateClient;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
final readonly class Output implements \Shared\DDD\Usecase\Output
{
public function __construct(
public ClientId $clientId,
public ClientIdentifier $clientIdentifier,
public ClientSecret $clientSecret,
public ClientName $clientName,
) {
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\FindAllClients;
use Application\Security\Models\Entities\Client;
use Application\Security\Ports\Repositories\ClientRepository;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Usecase;
use Shared\Paginated;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\FindAllClients\Input, \Application\Security\Usecases\FindAllClients\Output> */
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<Client> $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(),
);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\FindAllClients;
use Shared\Pagination;
final readonly class Input implements \Shared\DDD\Usecase\Input
{
public function __construct(
public Pagination $pagination,
) {
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\FindAllClients;
use Shared\DDD\Usecase\Output as UsecaseOutput;
final readonly class Output implements UsecaseOutput
{
/**
* @param array<PaginatedClient> $clients
*/
public function __construct(
public array $clients,
public int $total,
public int $page,
public int $perPage,
) {
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\FindAllClients;
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\Usecase\Output as UsecaseOutput;
final readonly class PaginatedClient implements UsecaseOutput
{
public function __construct(
public ClientId $id,
public ClientIdentifier $identifier,
public ClientName $name,
public bool $isActive,
public \DateTimeInterface $createdAt,
public \DateTimeInterface $updatedAt,
) {
}
public static function fromDomain(Client $client): self
{
return new self(
id: $client->id,
identifier: $client->identifier,
name: $client->name,
isActive: $client->isActive,
createdAt: $client->createdAt,
updatedAt: $client->updatedAt,
);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\RegenerateClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
final readonly class Input implements \Shared\DDD\Usecase\Input
{
public function __construct(
public ClientId $clientId,
) {
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\RegenerateClientSecret;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientSecret;
final readonly class Output implements \Shared\DDD\Usecase\Output
{
public function __construct(
public ClientSecret $newSecret,
public ClientIdentifier $clientIdentifier,
) {
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\RegenerateClientSecret;
use Application\Security\Events\ClientSecretRegenerated;
use Application\Security\Exceptions\ClientNotFoundException;
use Application\Security\Ports\Repositories\ClientRepository;
use Application\Security\Ports\Services\ClientCredentialGenerator;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Usecase;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\RegenerateClientSecret\Input, \Application\Security\Usecases\RegenerateClientSecret\Output> */
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);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\ToggleClientStatus;
use Application\Security\Models\ValueObjects\Entities\ClientId;
final readonly class Input implements \Shared\DDD\Usecase\Input
{
public function __construct(
public ClientId $clientId,
) {
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\ToggleClientStatus;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\Entities\ClientId;
final readonly class Output implements \Shared\DDD\Usecase\Output
{
public function __construct(
public ClientId $clientId,
public ClientIdentifier $clientIdentifier,
public ClientName $clientName,
public bool $isActive,
) {
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\ToggleClientStatus;
use Application\Security\Events\ClientStatusToggled;
use Application\Security\Exceptions\ClientNotFoundException;
use Application\Security\Ports\Repositories\ClientRepository;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Usecase;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\ToggleClientStatus\Input, \Application\Security\Usecases\ToggleClientStatus\Output> */
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,
);
}
}

View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\Usecases\CreateClient;
use Application\Security\Events\ClientSecretGenerated;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Application\Security\Ports\Services\ClientCredentialGenerator;
use Application\Security\Usecases\CreateClient\CreateClient;
use Application\Security\Usecases\CreateClient\Input;
use Application\Security\Usecases\CreateClient\Output;
use Infra\Adapters\Repositories\InMemoryClientRepository;
use Mockery;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Event\DomainEventDispatcher;
covers(
CreateClient::class,
Input::class,
Output::class,
);
describe('CreateClient Usecase Unit Tests', function (): void {
$repository = new InMemoryClientRepository();
beforeEach(function () use ($repository): void {
$repository->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);
});
});

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\Usecases\FindAllClients;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Application\Security\Usecases\FindAllClients\FindAllClients;
use Application\Security\Usecases\FindAllClients\Input;
use Application\Security\Usecases\FindAllClients\Output;
use Application\Security\Usecases\FindAllClients\PaginatedClient;
use Infra\Adapters\Repositories\InMemoryClientRepository;
use Ramsey\Uuid\Uuid;
use Shared\Pagination;
covers(
FindAllClients::class,
Input::class,
Output::class,
PaginatedClient::class,
);
describe('FindAllClients Usecase Unit Tests', function (): void {
$repository = new InMemoryClientRepository();
beforeEach(function () use ($repository): void {
$repository->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();
});
});

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\Usecases\RegenerateClientSecret;
use Application\Security\Exceptions\ClientNotFoundException;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Application\Security\Ports\Services\ClientCredentialGenerator;
use Application\Security\Usecases\RegenerateClientSecret\Input;
use Application\Security\Usecases\RegenerateClientSecret\Output;
use Application\Security\Usecases\RegenerateClientSecret\RegenerateClientSecret;
use Infra\Adapters\Repositories\InMemoryClientRepository;
use Mockery;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Event\DomainEventDispatcher;
covers(
RegenerateClientSecret::class,
Input::class,
Output::class,
);
describe('RegenerateClientSecret Usecase Unit Tests', function (): void {
$repository = new InMemoryClientRepository();
beforeEach(function () use ($repository): void {
$repository->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);
});
});

View file

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\Usecases\ToggleClientStatus;
use Application\Security\Events\ClientStatusToggled;
use Application\Security\Exceptions\ClientNotFoundException;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Application\Security\Usecases\ToggleClientStatus\Input;
use Application\Security\Usecases\ToggleClientStatus\Output;
use Application\Security\Usecases\ToggleClientStatus\ToggleClientStatus;
use Infra\Adapters\Repositories\InMemoryClientRepository;
use Mockery;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Event\DomainEventDispatcher;
covers(
ToggleClientStatus::class,
Input::class,
Output::class,
);
describe('ToggleClientStatus Usecase Unit Tests', function (): void {
$repository = new InMemoryClientRepository();
beforeEach(function () use ($repository): void {
$repository->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);
});
});

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\ValueObjects;
use Application\Security\Models\ValueObjects\ClientIdentifier;
covers(ClientIdentifier::class);
describe('ClientIdentifier Value Object Instantiation', function (): void {
it('instantiates successfully with a valid client identifier', function (string $input): void {
// GIVEN
$validClientIdentifier = $input;
// WHEN
$clientIdentifier = new ClientIdentifier($validClientIdentifier);
// THEN
expect($clientIdentifier->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.'
);
});
});

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\ValueObjects;
use Application\Security\Models\ValueObjects\ClientName;
covers(ClientName::class);
describe('ClientName Value Object Instantiation', function (): void {
it('accepts valid client names', function (string $input): void {
// GIVEN
$value = $input;
// WHEN
$clientName = new ClientName($value);
// THEN
expect($clientName->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.'
);
});
});

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\ValueObjects;
use Application\Security\Models\ValueObjects\ClientSecret;
covers(ClientSecret::class);
describe('ClientSecret Value Object Instantiation', function (): void {
it('accepts valid client secrets', function (string $input): void {
// GIVEN
$value = $input;
// WHEN
$clientSecret = new ClientSecret($value);
// THEN
expect($clientSecret->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),
]);
});

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\ValueObjects\Entities;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Shared\Helpers\Exceptions\InvalidUuidException;
covers(ClientId::class);
describe('ClientId Value Object Instantiation', function (): void {
it('accepts valid UUIDv7 identifier', function (): void {
// GIVEN
$uuid7 = Uuid::uuid7();
// WHEN
$clientId = new ClientId($uuid7);
// THEN
expect($clientId->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(),
]);
});

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\ValueObjects\Request;
use Application\Security\Models\ValueObjects\Request\HttpBodyHash;
covers(HttpBodyHash::class);
describe('HttpBodyHash Value Object Instantiation', function (): void {
it('accepts valid SHA256 hex hashes', function (string $input): void {
// GIVEN
$value = $input;
// WHEN
$bodyHash = new HttpBodyHash($value);
// THEN
expect($bodyHash->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)
]);
});

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\ValueObjects\Request;
use Application\Security\Models\ValueObjects\Request\Nonce;
use Ramsey\Uuid\Uuid;
covers(Nonce::class);
describe('Nonce Value Object Instantiation', function (): void {
it('accepts valid UUID instance', function (): void {
// GIVEN
$uuid = Uuid::uuid7();
// WHEN
$nonce = new Nonce($uuid);
// THEN
expect($nonce->value)->toBe($uuid);
});
});

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\ValueObjects\Request;
use Application\Security\Models\ValueObjects\Request\RequestSignature;
covers(RequestSignature::class);
describe('RequestSignature Value Object Instantiation', function (): void {
it('instantiates successfully with a valid 64-character hex signature', function (): void {
// GIVEN
$validSignature = hash_hmac('sha256', 'test_payload', 'secret_key');
// WHEN
$signature = new RequestSignature($validSignature);
// THEN
expect($signature->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)
]);
});

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\ValueObjects\Request;
use Application\Security\Models\ValueObjects\Request\RequestTimestamp;
covers(RequestTimestamp::class);
describe('RequestTimestamp Value Object Instantiation', function (): void {
it('accepts valid DateTimeImmutable instance', function (): void {
// GIVEN
$dateTime = new \DateTimeImmutable('2026-08-18 10:00:00');
// WHEN
$timestamp = new RequestTimestamp($dateTime);
// THEN
expect($timestamp->value)->toBe($dateTime);
});
});