feat(security): implement security bounded context entities, value objects, ports, use cases and unit tests
This commit is contained in:
parent
909f7abaa4
commit
fa86b9c4e6
49 changed files with 1777 additions and 0 deletions
19
src/Application/Security/Events/ClientSecretGenerated.php
Normal file
19
src/Application/Security/Events/ClientSecretGenerated.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
19
src/Application/Security/Events/ClientSecretRegenerated.php
Normal file
19
src/Application/Security/Events/ClientSecretRegenerated.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
20
src/Application/Security/Events/ClientStatusToggled.php
Normal file
20
src/Application/Security/Events/ClientStatusToggled.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
87
src/Application/Security/Models/Entities/Client.php
Normal file
87
src/Application/Security/Models/Entities/Client.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
19
src/Application/Security/Models/ValueObjects/ClientName.php
Normal file
19
src/Application/Security/Models/ValueObjects/ClientName.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
13
src/Application/Security/Ports/Services/NonceValidator.php
Normal file
13
src/Application/Security/Ports/Services/NonceValidator.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
13
src/Application/Security/Ports/Services/RequestValidator.php
Normal file
13
src/Application/Security/Ports/Services/RequestValidator.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
17
src/Application/Security/Usecases/CreateClient/Input.php
Normal file
17
src/Application/Security/Usecases/CreateClient/Input.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
21
src/Application/Security/Usecases/CreateClient/Output.php
Normal file
21
src/Application/Security/Usecases/CreateClient/Output.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/Application/Security/Usecases/FindAllClients/Input.php
Normal file
15
src/Application/Security/Usecases/FindAllClients/Input.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
21
src/Application/Security/Usecases/FindAllClients/Output.php
Normal file
21
src/Application/Security/Usecases/FindAllClients/Output.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
]);
|
||||
});
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
});
|
||||
|
|
@ -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)
|
||||
]);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
]);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue