feat(infra): add ApiClient Eloquent model, repository adapter, security services, migrations, seeders and tests

This commit is contained in:
Jérémy Munsch 2026-08-18 12:03:29 +02:00
parent fa86b9c4e6
commit e7904682b6
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
30 changed files with 1564 additions and 14 deletions

View file

@ -55,6 +55,7 @@ FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
ARCADIA_CACHE_STORE=redis
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
@ -83,3 +84,5 @@ VITE_APP_NAME="${APP_NAME}"
THROTTLE_WEB=120
THROTTLE_API_V1=60
ARCADIA_AUTH_DEV_BYPASS=false

View file

@ -29,10 +29,13 @@ BROADCAST_CONNECTION=null
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
CACHE_STORE=array
MAIL_MAILER=array
MAIL_MAILER=
ARCADIA_CACHE_STORE=array
SPEC_PATH=documentation/OpenApi
PULSE_ENABLED=false
TELESCOPE_ENABLED=false
NIGHTWATCH_ENABLED=false
ARCADIA_AUTH_DEV_BYPASS=true

View file

@ -4,8 +4,10 @@ declare(strict_types=1);
use Infra\Providers\AppServiceProvider;
use Infra\Providers\DomainServiceProvider;
use Infra\Providers\SecurityServiceProvider;
return [
DomainServiceProvider::class,
AppServiceProvider::class,
DomainServiceProvider::class,
SecurityServiceProvider::class,
];

View file

@ -116,4 +116,24 @@ return [
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
/*
|--------------------------------------------------------------------------
| HMAC Authentication Options
|--------------------------------------------------------------------------
|
| allow_dev_bypass: When enabled in local or testing environments, allows
| requests without HMAC signatures to proceed using a default dev client.
|
*/
'hmac' => [
'allow_dev_bypass' => (bool) env('ARCADIA_AUTH_DEV_BYPASS', false),
'dev_client' => [
'id' => env('ARCADIA_DEV_CLIENT_ID', '01915ec0-0000-7000-8000-000000000001'),
'name' => env('ARCADIA_DEV_CLIENT_NAME', 'Arcadia Dev Test Client'),
'identifier' => env('ARCADIA_DEV_CLIENT_IDENTIFIER', 'arcadia_id_00000000000000000000000000000001'),
'secret' => env('ARCADIA_DEV_CLIENT_SECRET', 'arcadia_sec_00000000000000000000000000000000000000000000000000000000000000000000000000000000000001'),
],
],
];

View file

@ -19,6 +19,8 @@ return [
'default' => env('CACHE_STORE', 'database'),
'arcadia_store' => env('ARCADIA_CACHE_STORE', env('CACHE_STORE', 'database')),
/*
|--------------------------------------------------------------------------
| Cache Stores

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use Application\Security\Ports\Services\ClientCredentialGenerator;
use Illuminate\Database\Eloquent\Factories\Factory;
use Infra\Database\Models\ApiClient;
use Ramsey\Uuid\Uuid;
/**
* @extends Factory<ApiClient>
*/
final class ApiClientFactory extends Factory
{
protected $model = ApiClient::class;
/**
* @return array<string, mixed>
*/
#[\Override]
public function definition(): array
{
$clientIdentifierGenerator = resolve(ClientCredentialGenerator::class);
return [
'id' => Uuid::uuid7()->toString(),
'name' => fake()->company(),
'identifier' => $clientIdentifierGenerator->generateIdentifier()->value,
'secret' => $clientIdentifierGenerator->generateSecret()->value,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
];
}
}

View file

@ -30,6 +30,8 @@ final class EmployeeFactory extends Factory
'establishment' => '01',
'site' => 'LDCSER',
'service' => '097',
'created_at' => now()->toDateTimeString(),
'updated_at' => now()->toDateTimeString(),
];
}
}

View file

@ -18,10 +18,11 @@ return new class () extends Migration
$table->string('name');
$table->string('identifier')->unique();
$table->string('secret');
$table->text('secret');
$table->boolean('is_active');
$table->timestamps();
$table->timestamp('created_at');
$table->timestamp('updated_at');
});
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Config;
use Infra\Database\Models\ApiClient;
final class ApiClientSeeder extends Seeder
{
public function run(): void
{
/** @var array{id: string, name: string, identifier: string, secret: string} $devClient */
$devClient = Config::array('auth.hmac.dev_client');
ApiClient::query()->updateOrCreate(
['identifier' => $devClient['identifier']],
[
'id' => $devClient['id'],
'name' => $devClient['name'],
'secret' => $devClient['secret'],
'is_active' => true,
]
);
$this->command?->info('API client seeded successfully.');
$this->command?->info('Client ID: ' . $devClient['id']);
$this->command?->info('Client Identifier: ' . $devClient['identifier']);
$this->command?->info('Client Secret: ' . $devClient['secret']);
}
}

View file

@ -16,6 +16,7 @@ final class DatabaseSeeder extends Seeder
$this->call([
EmployeeSeeder::class,
IncoSheetSeeder::class,
ApiClientSeeder::class,
]);
}
}

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Infra\Adapters\Repositories;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Application\Security\Ports\Repositories\ClientRepository;
use Infra\Database\Models\ApiClient;
use Shared\Paginated;
use Shared\Pagination;
final class ApiClientRepository implements ClientRepository
{
#[\Override]
public function persist(Client $client): void
{
ApiClient::query()->updateOrCreate(
['id' => $client->id->value],
[
'name' => $client->name,
'identifier' => $client->identifier,
'secret' => $client->secret,
'is_active' => $client->isActive,
]
);
}
#[\Override]
public function findByIdentifier(ClientIdentifier $identifier): ?Client
{
return transform(
ApiClient::query()
->where('identifier', $identifier->value)
->first(),
fn (ApiClient $apiClient) => $this->toDomain($apiClient)
);
}
#[\Override]
public function find(ClientId $id): ?Client
{
return transform(
ApiClient::query()->where('id', $id->value)->first(),
fn (ApiClient $apiClient) => $this->toDomain($apiClient)
);
}
#[\Override]
public function findAll(Pagination $pagination): Paginated
{
$query = ApiClient::query();
$total = $query->count();
$items = $query->forPage(
$pagination->pageNumber(),
$pagination->pageSize()
)->get();
return new Paginated(
pagination: $pagination,
items: $items->map(fn (ApiClient $apiClient) => $this->toDomain($apiClient))->all(),
total: $total,
);
}
#[\Override]
public function delete(Client $client): void
{
$deleted = ApiClient::query()->where('id', $client->id->value)->delete();
if ($deleted === 0) {
throw new \LogicException('Client not found');
}
}
private function toDomain(ApiClient $apiClient): Client
{
return Client::hydrate(
id: new ClientId($apiClient->id),
name: $apiClient->name,
identifier: $apiClient->identifier,
secret: $apiClient->secret,
isActive: $apiClient->is_active,
createdAt: $apiClient->created_at,
updatedAt: $apiClient->updated_at,
);
}
}

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Infra\Adapters\Services;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Request\Nonce;
use Application\Security\Ports\Services\NonceValidator;
use Application\Security\Ports\Services\TimestampValidator;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
final readonly class CachedNonceValidator implements NonceValidator
{
private const string CACHE_KEY_PREFIX = 'nonce:';
private const int CACHE_TTL_SECONDS = TimestampValidator::ALLOWED_TIMESTAMP_SKEW_SECONDS;
public function __construct(
private readonly LoggerInterface $logger,
private readonly CacheInterface $cacheStore,
) {
}
#[\Override]
public function validateAndConsume(Nonce $nonce, ClientIdentifier $forClientIdentifier): bool
{
$key = $this->getCacheKey($forClientIdentifier, $nonce);
if ($this->isKeyConsumed($key)) {
$this->logger->debug(
'Nonce already used for client: {clientIdentifier}, nonce: {nonce}',
[
'clientIdentifier' => $forClientIdentifier->value,
'nonce' => $nonce->value,
]
);
return false;
}
$this->consumeKey($key);
return true;
}
private function isKeyConsumed(string $key): bool
{
return $this->cacheStore->has($key);
}
private function consumeKey(string $key): void
{
$this->cacheStore->set(
$key,
'used',
self::CACHE_TTL_SECONDS
);
$this->logger->debug(
'Nonce consumed for key: {key}',
['key' => $key]
);
}
private function getCacheKey(ClientIdentifier $forClientIdentifier, Nonce $nonce): string
{
return sprintf(
self::CACHE_KEY_PREFIX . '%s:%s',
$forClientIdentifier->value,
$nonce->value
);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Infra\Adapters\Services;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Ports\Services\ClientCredentialGenerator;
final readonly class ClientCredentialGeneratorImpl implements ClientCredentialGenerator
{
private const string IDENTIFIER_PREFIX = 'arcadia_id_';
private const string SECRET_PREFIX = 'arcadia_sec_';
#[\Override]
public function generateIdentifier(): ClientIdentifier
{
$random = bin2hex(random_bytes(16));
return new ClientIdentifier(self::IDENTIFIER_PREFIX . $random);
}
#[\Override]
public function generateSecret(): ClientSecret
{
$random = bin2hex(random_bytes(32));
$random = base64_encode($random);
$random = strtr($random, '+/', '');
$random = rtrim($random, '=');
return new ClientSecret(self::SECRET_PREFIX . $random);
}
}

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Infra\Adapters\Services;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Request\Request;
use Application\Security\Models\ValueObjects\Request\RequestSignature;
use Application\Security\Ports\Services\HmacSignatureValidator;
use Uri\Rfc3986\Uri;
final readonly class HmacSignatureValidatorImpl implements HmacSignatureValidator
{
private const string SHA256_EMPTY_STRING_HASH = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
#[\Override]
public function validate(Request $request, ClientSecret $withSecret): bool
{
$payloadToSign = $this->compileDataToSign($request);
$expectedSignature = $this->calculateSignature(
$payloadToSign,
$withSecret
);
return $this->compareSignatures(
$expectedSignature,
$request->signature
);
}
private function compareSignatures(string $expectedSignature, RequestSignature $providedSignature): bool
{
// NOTE: Use hash_equals to prevent timing attacks when comparing signatures.
return hash_equals($expectedSignature, $providedSignature->value);
}
/** @param list<string> $signaturePayload */
private function calculateSignature(array $signaturePayload, ClientSecret $withSecret): string
{
$payloadString = implode('|', $signaturePayload);
return hash_hmac('sha256', $payloadString, $withSecret->value);
}
/** @return list<string> */
private function compileDataToSign(Request $request): array
{
return [
$request->method->name,
$this->getOrdonatedPathAndQuery($request->uri),
$request->timestamp->value->format(\DateTimeInterface::ATOM),
$request->nonce->value->toString(),
$request->bodyHash->value ?? self::SHA256_EMPTY_STRING_HASH,
];
}
private function getOrdonatedPathAndQuery(Uri $fromUri): string
{
$path = $fromUri->getPath();
$query = $fromUri->getQuery();
if ($query === null || $query === '') {
return $path;
}
// NOTE: Sort query parameters to ensure consistent signature generation
parse_str($query, $queryParams);
ksort($queryParams);
$sortedQuery = http_build_query($queryParams);
return $path . '?' . $sortedQuery;
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Infra\Adapters\Services;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\Request\Request;
use Application\Security\Ports\Repositories\ClientRepository;
use Application\Security\Ports\Services\HmacSignatureValidator;
use Application\Security\Ports\Services\NonceValidator;
use Application\Security\Ports\Services\RequestValidation\RequestValidationResult;
use Application\Security\Ports\Services\RequestValidation\RequestValidationStatus;
use Application\Security\Ports\Services\RequestValidator;
use Application\Security\Ports\Services\TimestampValidator;
use Psr\Log\LoggerInterface;
final readonly class RequestValidatorImpl implements RequestValidator
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly HmacSignatureValidator $signatureValidator,
private readonly NonceValidator $nonceValidator,
private readonly TimestampValidator $timestampValidator,
private readonly ClientRepository $clientRepository,
) {
}
#[\Override]
public function validate(Request $request): RequestValidationResult
{
$client = $this->findClientFrom($request);
$firstFailure = null;
if ($client === null) {
$this->logger->debug(
'Client not found for identifier: {identifier}',
['identifier' => $request->identifier]
);
$firstFailure = RequestValidationStatus::CLIENT_NOT_FOUND;
}
if ($client !== null && ! $client->isActive) {
$this->logger->debug(
'Client is disabled for identifier: {identifier}',
['identifier' => $request->identifier]
);
$firstFailure ??= RequestValidationStatus::CLIENT_NOT_AUTHORIZED;
}
if (! $this->validateTimestampFrom($request)) {
$this->logger->debug(
'Invalid timestamp: {timestamp}',
['timestamp' => $request->timestamp->value->format(\DateTimeInterface::ATOM)]
);
$firstFailure ??= RequestValidationStatus::INVALID_TIMESTAMP;
}
if ($client !== null && ! $this->validateSignatureFrom($request, $client)) {
$this->logger->debug(
'Invalid signature for client: {clientId}',
['clientId' => $request->identifier]
);
$firstFailure ??= RequestValidationStatus::INVALID_SIGNATURE;
}
if ($firstFailure === null && ! $this->validateNonceFrom($request)) {
$this->logger->debug(
'Invalid nonce: {nonce}',
['nonce' => $request->nonce]
);
return RequestValidationResult::failed(RequestValidationStatus::INVALID_NONCE);
}
if ($firstFailure !== null) {
return RequestValidationResult::failed($firstFailure);
}
/** @var Client $client */
return RequestValidationResult::valid($client);
}
private function findClientFrom(Request $request): ?Client
{
return $this->clientRepository->findByIdentifier(
identifier: $request->identifier
);
}
private function validateNonceFrom(Request $request): bool
{
return $this->nonceValidator->validateAndConsume(
nonce: $request->nonce,
forClientIdentifier: $request->identifier
);
}
private function validateTimestampFrom(Request $request): bool
{
return $this->timestampValidator->validate(
timestamp: $request->timestamp->value
);
}
private function validateSignatureFrom(Request $request, Client $forClient): bool
{
return $this->signatureValidator->validate(
request: $request,
withSecret: $forClient->secret
);
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Infra\Adapters\Services;
use Application\Security\Ports\Services\TimestampValidator;
final readonly class TimestampValidatorImpl implements TimestampValidator
{
public function __construct(
private \DateTimeZone $timezone
) {
}
#[\Override]
public function validate(\DateTimeInterface $timestamp): bool
{
$allowedSkew = $this->getAllowedSkew();
return $this->isTimestampInSkew($timestamp, $allowedSkew);
}
private function isTimestampInSkew(\DateTimeInterface $timestamp, \DateInterval $allowedSkew): bool
{
$now = $this->getCurrentTime();
return ($timestamp >= $now->sub($allowedSkew)) && ($timestamp <= $now->add($allowedSkew));
}
private function getCurrentTime(): \DateTimeImmutable
{
return new \DateTimeImmutable('now', $this->timezone);
}
private function getAllowedSkew(): \DateInterval
{
return new \DateInterval('PT' . TimestampValidator::ALLOWED_TIMESTAMP_SKEW_SECONDS . 'S');
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Infra\Casts;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
/** @implements CastsAttributes<ClientIdentifier, string|ClientIdentifier> */
final class ClientIdentifierCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ClientIdentifier
{
if ($value === null) {
throw new \InvalidArgumentException('ClientIdentifier value cannot be null');
}
if (! is_string($value)) {
throw new \InvalidArgumentException('Value must be a string');
}
return new ClientIdentifier($value);
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if ($value === null) {
throw new \InvalidArgumentException('ClientIdentifier value cannot be null');
}
if ($value instanceof ClientIdentifier) {
return $value->value;
}
if (is_string($value)) {
return (new ClientIdentifier($value))->value;
}
throw new \InvalidArgumentException('Value must be a string or instance of ClientIdentifier');
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Infra\Casts;
use Application\Security\Models\ValueObjects\ClientName;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
/** @implements CastsAttributes<ClientName, string|ClientName> */
final class ClientNameCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ClientName
{
if ($value === null) {
throw new \InvalidArgumentException('ClientName value cannot be null');
}
if (! is_string($value)) {
throw new \InvalidArgumentException('Value must be a string');
}
return new ClientName($value);
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if ($value === null) {
throw new \InvalidArgumentException('ClientName value cannot be null');
}
if ($value instanceof ClientName) {
return $value->value;
}
if (is_string($value)) {
return (new ClientName($value))->value;
}
throw new \InvalidArgumentException('Value must be a string or instance of ClientName');
}
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Infra\Casts;
use Application\Security\Models\ValueObjects\ClientSecret;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
/** @implements CastsAttributes<ClientSecret, string|ClientSecret> */
final class ClientSecretCast implements CastsAttributes
{
/**
* @param array<string, mixed> $attributes
*/
#[\Override]
public function get(Model $model, string $key, mixed $value, array $attributes): ClientSecret
{
if ($value === null) {
throw new \InvalidArgumentException('ClientSecret value cannot be null');
}
if (! is_string($value)) {
throw new \InvalidArgumentException('Value must be a string');
}
try {
$decrypted = Crypt::decryptString($value);
} catch (DecryptException) {
$decrypted = $value;
}
return new ClientSecret($decrypted);
}
/**
* @param array<string, mixed> $attributes
*/
#[\Override]
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if ($value === null) {
throw new \InvalidArgumentException('ClientSecret value cannot be null');
}
$plainSecret = match (true) {
$value instanceof ClientSecret => $value->value,
is_string($value) => (new ClientSecret($value))->value,
default => throw new \InvalidArgumentException('Value must be a string or instance of ClientSecret'),
};
return Crypt::encryptString($plainSecret);
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Infra\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Shared\Helpers\UuidHelper;
/** @implements CastsAttributes<\Ramsey\Uuid\UuidInterface, string> */
final class UuidCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): UuidInterface
{
if ($value === null) {
throw new \InvalidArgumentException('UUID value cannot be null');
}
if (! is_string($value)) {
throw new \InvalidArgumentException('Value must be a string');
}
return Uuid::fromString($value);
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if ($value === null) {
throw new \InvalidArgumentException('UUID value cannot be null');
}
if (is_string($value)) {
$value = Uuid::fromString($value);
}
if (! $value instanceof UuidInterface) {
throw new \InvalidArgumentException('Value must be a string or instance of UuidInterface');
}
UuidHelper::validateUuid($value);
return (string) $value;
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Infra\Database\Models;
use Database\Factories\ApiClientFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Infra\Casts\ClientIdentifierCast;
use Infra\Casts\ClientNameCast;
use Infra\Casts\ClientSecretCast;
use Infra\Casts\UuidCast;
/**
* @property \Ramsey\Uuid\UuidInterface $id
* @property \Application\Security\Models\ValueObjects\ClientName $name
* @property \Application\Security\Models\ValueObjects\ClientIdentifier $identifier
* @property \Application\Security\Models\ValueObjects\ClientSecret $secret
* @property bool $is_active
* @property \Illuminate\Support\Carbon $created_at
* @property \Illuminate\Support\Carbon $updated_at
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
*
* @method static \Database\Factories\ApiClientFactory factory($count = null, $state = [])
*
* @mixin \Eloquent
*/
final class ApiClient extends Authenticatable
{
/** @use HasFactory<ApiClientFactory> */
use HasFactory;
use HasUuids;
use Notifiable;
protected static function newFactory(): ApiClientFactory
{
return ApiClientFactory::new();
}
protected $table = 'api_clients';
public $timestamps = true;
/** @var list<string> */
protected $fillable = [
'id',
'name',
'identifier',
'secret',
'is_active',
'created_at',
'updated_at',
];
/** @var list<string> */
protected $hidden = [
'secret',
];
/** @return array<string, string> */
#[\Override]
protected function casts(): array
{
return [
'id' => UuidCast::class,
'name' => ClientNameCast::class,
'identifier' => ClientIdentifierCast::class,
'secret' => ClientSecretCast::class,
'is_active' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Infra\Providers;
use Application\Security\Ports\Repositories\ClientRepository;
use Application\Security\Ports\Services\ClientCredentialGenerator;
use Application\Security\Ports\Services\HmacSignatureValidator;
use Application\Security\Ports\Services\NonceValidator;
use Application\Security\Ports\Services\RequestValidator;
use Application\Security\Ports\Services\TimestampValidator;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
use Infra\Adapters\Repositories\ApiClientRepository;
use Infra\Adapters\Services\CachedNonceValidator;
use Infra\Adapters\Services\ClientCredentialGeneratorImpl;
use Infra\Adapters\Services\HmacSignatureValidatorImpl;
use Infra\Adapters\Services\RequestValidatorImpl;
use Infra\Adapters\Services\TimestampValidatorImpl;
use Presentation\Api\V1\Security\ApiUserDetailsProvider;
use Presentation\Console\Security\ConsoleUserDetailsProvider;
use Psr\Log\LoggerInterface;
use Shared\DDD\Security\UserDetailsProvider;
final class SecurityServiceProvider extends ServiceProvider
{
#[\Override]
public function register(): void
{
$this->app->bind(ClientCredentialGenerator::class, ClientCredentialGeneratorImpl::class);
$this->app->bind(ClientRepository::class, ApiClientRepository::class);
$this->app->bind(HmacSignatureValidator::class, HmacSignatureValidatorImpl::class);
$this->app->bind(NonceValidator::class, function (Application $app): CachedNonceValidator {
$storeName = Config::string('cache.arcadia_store');
/** @var \Psr\SimpleCache\CacheInterface $cache */
$cache = Cache::store($storeName);
return new CachedNonceValidator(
logger: $app->make(LoggerInterface::class),
cacheStore: $cache,
);
});
$this->app->bind(TimestampValidator::class, function () {
$timezone = new \DateTimeZone(Config::string('app.timezone'));
return new TimestampValidatorImpl($timezone);
});
$this->app->bind(RequestValidator::class, RequestValidatorImpl::class);
if ($this->app->runningInConsole()) {
$this->app->bind(UserDetailsProvider::class, ConsoleUserDetailsProvider::class);
} else {
$this->app->scoped(ApiUserDetailsProvider::class);
$this->app->bind(UserDetailsProvider::class, ApiUserDetailsProvider::class);
}
}
}

View file

@ -6,55 +6,70 @@ namespace Infra\Usecase;
use Illuminate\Database\DatabaseManager;
use Psr\Log\LoggerInterface;
use Shared\DDD\Security\UserDetailsProvider;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
use Shared\DDD\Usecase\Input;
use Shared\DDD\Usecase\Presenter;
use Shared\DDD\Usecase\Usecase;
/** @implements UsecaseExecutor<Input, \Shared\DDD\Usecase\Output> */
final readonly class LaravelExecutor implements UsecaseExecutor
{
public function __construct(
private DatabaseManager $database,
private LoggerInterface $logger,
private ?UserDetailsProvider $userDetailsProvider = null,
) {
}
/**
* @template TInput of Input
* @template TOutput of \Shared\DDD\Usecase\Output
* @template TView of object
*
* @param Usecase<Input, \Shared\DDD\Usecase\Output> $usecase
* @param Presenter<\Shared\DDD\Usecase\Output, TView> $presenter
* @param Usecase<TInput, TOutput> $usecase
* @param TInput $input
* @param Presenter<TOutput, TView> $presenter
* @return TView
*/
#[\Override]
public function execute(Usecase $usecase, Input $input, Presenter $presenter): object
{
$currentUser = $this->userDetailsProvider?->currentUser();
$context = [
'usecase' => $usecase::class,
'input' => $input::class,
'presenter' => $presenter::class,
'user' => $currentUser ? [
'id' => $currentUser->id(),
'name' => $currentUser->name(),
'type' => $currentUser->type()->type(),
'identifier' => $currentUser->identifier(),
] : null,
];
$this->logger->debug('Usecase execution started', $context);
$this->logger->debug('Starting usecase execution', $context);
try {
/** @var TView $result */
$result = $this->database->transaction(function () use ($usecase, $input, $presenter): object {
$output = $usecase->execute($input);
return $presenter->present($output);
});
$this->logger->info('Usecase execution succeeded', $context);
$this->logger->info('Usecase executed successfully', $context);
/** @var TView */
return $result;
} catch (\Throwable $exception) {
$this->logger->error('Usecase execution failed', $context + [
'exception' => $exception,
} catch (\Throwable $e) {
$this->logger->error('Usecase execution failed: ' . $e->getMessage(), [
...$context,
'exception' => $e::class,
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
throw $exception;
throw $e;
}
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Tests\Integration\Database\Seeders;
use Database\Seeders\ApiClientSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Infra\Database\Models\ApiClient;
use Tests\TestCase;
covers(ApiClient::class);
uses(TestCase::class, RefreshDatabase::class);
describe('ApiClientSeeder Integration Test', function (): void {
it('seeds default api client records successfully', function (): void {
// GIVEN
$seeder = new ApiClientSeeder();
// WHEN
$seeder->run();
// THEN
$client = ApiClient::query()
->where('name', 'Arcadia Dev Test Client')
->first();
expect($client)->not->toBeNull()
->and($client?->is_active)->toBeTrue();
});
});

View file

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Tests\Integration\Infra\Adapters\Repositories;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use DateTimeImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Infra\Adapters\Repositories\ApiClientRepository;
use Infra\Database\Models\ApiClient;
use Ramsey\Uuid\Uuid;
use Shared\Pagination;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
covers(ApiClientRepository::class);
beforeEach(function (): void {
ApiClient::query()->delete();
});
describe('ApiClientRepository Integration Tests', function (): void {
it('persists a new client into database', function (): void {
// GIVEN
$repository = new ApiClientRepository();
$clientId = new ClientId(Uuid::uuid7());
$identifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32));
$secret = new ClientSecret('arcadia_sec_' . str_repeat('b', 86));
$client = Client::hydrate(
id: $clientId,
name: new ClientName('New API Client'),
identifier: $identifier,
secret: $secret,
isActive: true,
createdAt: new DateTimeImmutable(),
updatedAt: new DateTimeImmutable(),
);
// WHEN
$repository->persist($client);
// THEN
$found = $repository->find($clientId);
expect($found)->not->toBeNull()
->and($found->id->value)->toBe($clientId->value)
->and($found->name->value)->toBe('New API Client')
->and($found->identifier->value)->toBe($identifier->value)
->and($found->secret->value)->toBe($secret->value)
->and($found->isActive)->toBeTrue();
});
it('updates an existing client in database on persist', function (): void {
// GIVEN
$repository = new ApiClientRepository();
$rawId = Uuid::uuid7()->toString();
$identifier = 'arcadia_id_' . str_repeat('a', 32);
$oldSecret = 'arcadia_sec_' . str_repeat('1', 86);
$newSecret = 'arcadia_sec_' . str_repeat('2', 86);
ApiClient::query()->create([
'id' => $rawId,
'name' => 'Initial Client',
'identifier' => $identifier,
'secret' => $oldSecret,
'is_active' => true,
]);
$clientId = new ClientId(Uuid::fromString($rawId));
$client = Client::hydrate(
id: $clientId,
name: new ClientName('Initial Client'),
identifier: new ClientIdentifier($identifier),
secret: new ClientSecret($oldSecret),
isActive: true,
createdAt: new DateTimeImmutable(),
updatedAt: new DateTimeImmutable(),
);
$client->regenerateSecret(new ClientSecret($newSecret));
// WHEN
$repository->persist($client);
// THEN
$found = $repository->find($clientId);
expect($found)->not->toBeNull()
->and($found->secret->value)->toBe($newSecret);
});
it('finds a client by its ClientId', function (): void {
// GIVEN
$repository = new ApiClientRepository();
$rawId = Uuid::uuid7()->toString();
$identifier = 'arcadia_id_' . str_repeat('a', 32);
$secret = 'arcadia_sec_' . str_repeat('b', 86);
ApiClient::query()->create([
'id' => $rawId,
'name' => 'Existing Client',
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
]);
$clientId = new ClientId(Uuid::fromString($rawId));
// WHEN
$client = $repository->find($clientId);
// THEN
expect($client)->not->toBeNull()
->and($client->id->value)->toBe($rawId)
->and($client->name->value)->toBe('Existing Client')
->and($client->identifier->value)->toBe($identifier);
});
it('finds a client by its ClientIdentifier', function (): void {
// GIVEN
$repository = new ApiClientRepository();
$rawId = Uuid::uuid7()->toString();
$identifier = 'arcadia_id_' . str_repeat('a', 32);
$secret = 'arcadia_sec_' . str_repeat('b', 86);
ApiClient::query()->create([
'id' => $rawId,
'name' => 'Searchable Client',
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
]);
$clientIdentifier = new ClientIdentifier($identifier);
// WHEN
$client = $repository->findByIdentifier($clientIdentifier);
// THEN
expect($client)->not->toBeNull()
->and($client->identifier->value)->toBe($identifier)
->and($client->name->value)->toBe('Searchable Client');
});
it('deletes an existing client from database', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$repository = new ApiClientRepository();
$rawId = Uuid::uuid7()->toString();
$identifier = 'arcadia_id_' . str_repeat('a', 32);
$secret = 'arcadia_sec_' . str_repeat('b', 86);
ApiClient::query()->create([
'id' => $rawId,
'name' => 'Client to Delete',
'identifier' => $identifier,
'secret' => $secret,
'is_active' => true,
]);
$client = Client::hydrate(
id: new ClientId(Uuid::fromString($rawId)),
name: new ClientName('Client to Delete'),
identifier: new ClientIdentifier($identifier),
secret: new ClientSecret($secret),
isActive: true,
createdAt: new DateTimeImmutable(),
updatedAt: new DateTimeImmutable(),
);
// WHEN
$repository->delete($client);
// THEN
$this->assertDatabaseMissing('api_clients', [
'id' => $rawId,
]);
});
it('finds all clients with pagination', function (): void {
// GIVEN
$repository = new ApiClientRepository();
for ($i = 1; $i <= 5; $i++) {
ApiClient::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => "Client $i",
'identifier' => 'arcadia_id_' . str_pad((string) $i, 32, '0', STR_PAD_LEFT),
'secret' => 'arcadia_sec_' . str_pad((string) $i, 86, '0', STR_PAD_LEFT),
'is_active' => true,
]);
}
$pagination = Pagination::ofPage(pageNumber: 1, pageSize: 2);
// WHEN
$paginated = $repository->findAll($pagination);
// THEN
expect($paginated->getTotal())->toBe(5)
->and(count($paginated->getItems()))->toBe(2)
->and($paginated->getPagination()->pageNumber())->toBe(1);
});
});

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Adapters\Services;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Request\Nonce;
use Infra\Adapters\Services\CachedNonceValidator;
use Mockery;
use Psr\Log\NullLogger;
use Psr\SimpleCache\CacheInterface;
use Ramsey\Uuid\Uuid;
covers(CachedNonceValidator::class);
describe('CachedNonceValidator Unit Tests', function (): void {
it('validates and consumes a new nonce', function (): void {
// GIVEN
$cacheStore = Mockery::mock(CacheInterface::class);
$logger = new NullLogger();
$validator = new CachedNonceValidator($logger, $cacheStore);
$clientIdentifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32));
$nonce = new Nonce(Uuid::uuid7());
$expectedKey = 'nonce:' . $clientIdentifier->value . ':' . $nonce->value->toString();
$cacheStore->expects('has')
->once()
->with($expectedKey)
->andReturn(false);
$cacheStore->expects('set')
->once()
->with($expectedKey, 'used', 300)
->andReturn(true);
// WHEN
$isValid = $validator->validateAndConsume($nonce, $clientIdentifier);
// THEN
expect($isValid)->toBeTrue();
});
it('rejects an already consumed nonce', function (): void {
// GIVEN
$cacheStore = Mockery::mock(CacheInterface::class);
$logger = new NullLogger();
$validator = new CachedNonceValidator($logger, $cacheStore);
$clientIdentifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32));
$nonce = new Nonce(Uuid::uuid7());
$expectedKey = 'nonce:' . $clientIdentifier->value . ':' . $nonce->value->toString();
$cacheStore->expects('has')
->once()
->with($expectedKey)
->andReturn(true);
$cacheStore->shouldNotReceive('set');
// WHEN
$isValid = $validator->validateAndConsume($nonce, $clientIdentifier);
// THEN
expect($isValid)->toBeFalse();
});
});

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Adapters\Services;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientSecret;
use Infra\Adapters\Services\ClientCredentialGeneratorImpl;
covers(ClientCredentialGeneratorImpl::class);
describe('ClientCredentialGeneratorImpl Unit Tests', function (): void {
it('generates a valid client identifier', function (): void {
// GIVEN
$generator = new ClientCredentialGeneratorImpl();
// WHEN
$identifier = $generator->generateIdentifier();
// THEN
expect($identifier)->toBeInstanceOf(ClientIdentifier::class)
->and($identifier->value)->toStartWith('arcadia_id_')
->and(strlen($identifier->value))->toBe(43);
});
it('generates a valid client secret', function (): void {
// GIVEN
$generator = new ClientCredentialGeneratorImpl();
// WHEN
$secret = $generator->generateSecret();
// THEN
expect($secret)->toBeInstanceOf(ClientSecret::class)
->and($secret->value)->toStartWith('arcadia_sec_')
->and(strlen($secret->value))->toBeGreaterThanOrEqual(86)
->and(strlen($secret->value))->toBeLessThanOrEqual(120);
});
it('generates unique credentials on subsequent calls', function (): void {
// GIVEN
$generator = new ClientCredentialGeneratorImpl();
// WHEN
$id1 = $generator->generateIdentifier();
$id2 = $generator->generateIdentifier();
$sec1 = $generator->generateSecret();
$sec2 = $generator->generateSecret();
// THEN
expect($id1->value)->not->toBe($id2->value)
->and($sec1->value)->not->toBe($sec2->value);
});
});

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Adapters\Services;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Request\HttpMethod;
use Application\Security\Models\ValueObjects\Request\Nonce;
use Application\Security\Models\ValueObjects\Request\Request;
use Application\Security\Models\ValueObjects\Request\RequestSignature;
use Application\Security\Models\ValueObjects\Request\RequestTimestamp;
use DateTimeImmutable;
use DateTimeInterface;
use Infra\Adapters\Services\HmacSignatureValidatorImpl;
use Ramsey\Uuid\Uuid;
use Uri\Rfc3986\Uri;
covers(HmacSignatureValidatorImpl::class);
describe('HmacSignatureValidatorImpl Unit Tests', function (): void {
it('validates a matching HMAC signature', function (): void {
// GIVEN
$validator = new HmacSignatureValidatorImpl();
$secret = new ClientSecret(str_repeat('s', 98));
$identifier = new ClientIdentifier(str_repeat('c', 43));
$timestamp = new DateTimeImmutable('2026-08-17T12:00:00+00:00');
$nonce = new Nonce(Uuid::uuid7());
$uri = new Uri('http://localhost/api/v1/test?b=2&a=1');
$expectedPayload = implode('|', [
'GET',
'/api/v1/test?a=1&b=2',
$timestamp->format(DateTimeInterface::ATOM),
$nonce->value->toString(),
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
]);
$signatureHash = hash_hmac('sha256', $expectedPayload, $secret->value);
$signature = new RequestSignature($signatureHash);
$request = new Request(
uri: $uri,
method: HttpMethod::GET,
bodyHash: null,
signature: $signature,
nonce: $nonce,
identifier: $identifier,
timestamp: new RequestTimestamp($timestamp),
);
// WHEN
$isValid = $validator->validate($request, $secret);
// THEN
expect($isValid)->toBeTrue();
});
});

View file

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Adapters\Services;
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\Models\ValueObjects\Request\HttpMethod;
use Application\Security\Models\ValueObjects\Request\Nonce;
use Application\Security\Models\ValueObjects\Request\Request;
use Application\Security\Models\ValueObjects\Request\RequestSignature;
use Application\Security\Models\ValueObjects\Request\RequestTimestamp;
use Application\Security\Ports\Repositories\ClientRepository;
use Application\Security\Ports\Services\HmacSignatureValidator;
use Application\Security\Ports\Services\NonceValidator;
use Application\Security\Ports\Services\RequestValidation\RequestValidationStatus;
use Application\Security\Ports\Services\TimestampValidator;
use DateTimeImmutable;
use Infra\Adapters\Services\RequestValidatorImpl;
use Mockery;
use Psr\Log\NullLogger;
use Ramsey\Uuid\Uuid;
use Uri\Rfc3986\Uri;
covers(RequestValidatorImpl::class);
describe('RequestValidatorImpl Unit Tests', function (): void {
it('returns valid result when all security checks pass', function (): void {
// GIVEN
$logger = new NullLogger();
$signatureValidator = Mockery::mock(HmacSignatureValidator::class);
$nonceValidator = Mockery::mock(NonceValidator::class);
$timestampValidator = Mockery::mock(TimestampValidator::class);
$clientRepository = Mockery::mock(ClientRepository::class);
$validator = new RequestValidatorImpl(
$logger,
$signatureValidator,
$nonceValidator,
$timestampValidator,
$clientRepository
);
$clientIdentifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32));
$clientSecret = new ClientSecret('arcadia_sec_' . str_repeat('b', 86));
$client = Client::hydrate(
id: new ClientId(Uuid::uuid7()),
name: new ClientName('Test Client'),
identifier: $clientIdentifier,
secret: $clientSecret,
isActive: true,
createdAt: new DateTimeImmutable(),
updatedAt: new DateTimeImmutable(),
);
$request = new Request(
uri: new Uri('http://localhost/api/v1/test'),
method: HttpMethod::GET,
bodyHash: null,
signature: new RequestSignature(str_repeat('a', 64)),
nonce: new Nonce(Uuid::uuid7()),
identifier: $clientIdentifier,
timestamp: new RequestTimestamp(new DateTimeImmutable()),
);
$clientRepository->expects('findByIdentifier')
->with($clientIdentifier)
->andReturn($client);
$timestampValidator->expects('validate')
->once()
->andReturn(true);
$signatureValidator->expects('validate')
->once()
->with($request, $clientSecret)
->andReturn(true);
$nonceValidator->expects('validateAndConsume')
->once()
->with($request->nonce, $clientIdentifier)
->andReturn(true);
// WHEN
$result = $validator->validate($request);
// THEN
expect($result->isValid())->toBeTrue()
->and($result->status)->toBe(RequestValidationStatus::VALID)
->and($result->client)->toBe($client);
});
it('returns client not found when client does not exist', function (): void {
// GIVEN
$logger = new NullLogger();
$signatureValidator = Mockery::mock(HmacSignatureValidator::class);
$nonceValidator = Mockery::mock(NonceValidator::class);
$timestampValidator = Mockery::mock(TimestampValidator::class);
$clientRepository = Mockery::mock(ClientRepository::class);
$validator = new RequestValidatorImpl(
$logger,
$signatureValidator,
$nonceValidator,
$timestampValidator,
$clientRepository
);
$clientIdentifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32));
$request = new Request(
uri: new Uri('http://localhost/api/v1/test'),
method: HttpMethod::GET,
bodyHash: null,
signature: new RequestSignature(str_repeat('a', 64)),
nonce: new Nonce(Uuid::uuid7()),
identifier: $clientIdentifier,
timestamp: new RequestTimestamp(new DateTimeImmutable()),
);
$clientRepository->expects('findByIdentifier')
->with($clientIdentifier)
->andReturn(null);
$timestampValidator->expects('validate')
->once()
->andReturn(true);
// WHEN
$result = $validator->validate($request);
// THEN
expect($result->isValid())->toBeFalse()
->and($result->status)->toBe(RequestValidationStatus::CLIENT_NOT_FOUND)
->and($result->client)->toBeNull();
});
it('returns client not authorized when client is inactive', function (): void {
// GIVEN
$logger = new NullLogger();
$signatureValidator = Mockery::mock(HmacSignatureValidator::class);
$nonceValidator = Mockery::mock(NonceValidator::class);
$timestampValidator = Mockery::mock(TimestampValidator::class);
$clientRepository = Mockery::mock(ClientRepository::class);
$validator = new RequestValidatorImpl(
$logger,
$signatureValidator,
$nonceValidator,
$timestampValidator,
$clientRepository
);
$clientIdentifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32));
$clientSecret = new ClientSecret('arcadia_sec_' . str_repeat('b', 86));
$client = Client::hydrate(
id: new ClientId(Uuid::uuid7()),
name: new ClientName('Inactive Client'),
identifier: $clientIdentifier,
secret: $clientSecret,
isActive: false,
createdAt: new DateTimeImmutable(),
updatedAt: new DateTimeImmutable(),
);
$request = new Request(
uri: new Uri('http://localhost/api/v1/test'),
method: HttpMethod::GET,
bodyHash: null,
signature: new RequestSignature(str_repeat('a', 64)),
nonce: new Nonce(Uuid::uuid7()),
identifier: $clientIdentifier,
timestamp: new RequestTimestamp(new DateTimeImmutable()),
);
$clientRepository->expects('findByIdentifier')
->with($clientIdentifier)
->andReturn($client);
$timestampValidator->expects('validate')
->once()
->andReturn(true);
$signatureValidator->expects('validate')
->once()
->with($request, $clientSecret)
->andReturn(true);
// WHEN
$result = $validator->validate($request);
// THEN
expect($result->isValid())->toBeFalse()
->and($result->status)->toBe(RequestValidationStatus::CLIENT_NOT_AUTHORIZED);
});
});

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Adapters\Services;
use DateTimeImmutable;
use DateTimeZone;
use Infra\Adapters\Services\TimestampValidatorImpl;
covers(TimestampValidatorImpl::class);
describe('TimestampValidatorImpl Unit Tests', function (): void {
it('accepts timestamps within the 300 seconds skew window', function (string $modifier): void {
// GIVEN
$timezone = new DateTimeZone('UTC');
$validator = new TimestampValidatorImpl($timezone);
$timestamp = new DateTimeImmutable($modifier, $timezone);
// WHEN
$isValid = $validator->validate($timestamp);
// THEN
expect($isValid)->toBeTrue();
})->with([
'now',
'-2 minutes',
'+2 minutes',
'-299 seconds',
'+299 seconds',
]);
it('rejects timestamps outside the 300 seconds skew window', function (string $modifier): void {
// GIVEN
$timezone = new DateTimeZone('UTC');
$validator = new TimestampValidatorImpl($timezone);
$timestamp = new DateTimeImmutable($modifier, $timezone);
// WHEN
$isValid = $validator->validate($timestamp);
// THEN
expect($isValid)->toBeFalse();
})->with([
'-6 minutes',
'+6 minutes',
'-301 seconds',
'+301 seconds',
'-1 day',
'+1 day',
]);
});