feat(config): introduce typed configuration objects, dedicated value objects and enforce global class referencing

This commit is contained in:
Jérémy Munsch 2026-08-21 15:01:36 +02:00
parent 0063727790
commit 1df3a27c98
44 changed files with 1037 additions and 138 deletions

View file

@ -3,11 +3,13 @@
declare(strict_types=1);
use Infra\Providers\AppServiceProvider;
use Infra\Providers\ConfigServiceProvider;
use Infra\Providers\DomainServiceProvider;
use Infra\Providers\EventServiceProvider;
use Infra\Providers\SecurityServiceProvider;
return [
ConfigServiceProvider::class,
AppServiceProvider::class,
DomainServiceProvider::class,
EventServiceProvider::class,

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Application\Arcadia\Config;
use Shared\ValueObjects\Config\AppName;
use Shared\ValueObjects\Config\AppTimezone;
use Shared\ValueObjects\Config\Locale;
final readonly class AppConfig
{
public function __construct(
public AppName $name,
public AppTimezone $timezone,
public Locale $locale,
public Locale $fallbackLocale,
public bool $isDebug,
) {
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Application\Arcadia\Config;
use Shared\ValueObjects\Config\RequestsPerMinute;
final readonly class ThrottlingConfig
{
public function __construct(
public RequestsPerMinute $webLimit,
public RequestsPerMinute $apiV1Limit,
) {
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Application\Security\Config;
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 DevClientCredentials
{
public function __construct(
public ClientId $id,
public ClientName $name,
public ClientIdentifier $identifier,
public ClientSecret $secret,
) {
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Application\Security\Config;
final readonly class HmacAuthConfig
{
public function __construct(
public bool $allowDevBypass,
public ?DevClientCredentials $devClient = null,
) {
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Application\Security\Config;
use Shared\ValueObjects\Email;
final readonly class SecurityAlertMailConfig
{
public function __construct(
public Email $recipientEmail,
) {
}
}

View file

@ -10,11 +10,10 @@ use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogEntryId;
use Application\Security\Ports\Directories\ClientAuditLogDirectory;
use Illuminate\Database\Eloquent\Builder;
use Infra\Database\Models\ClientAuditLog;
use Override;
final class EloquentClientAuditLogDirectory implements ClientAuditLogDirectory
{
#[Override]
#[\Override]
public function find(SecurityAuditLogEntryId $id): ?SecurityAuditLogEntry
{
$record = ClientAuditLog::query()
@ -28,7 +27,7 @@ final class EloquentClientAuditLogDirectory implements ClientAuditLogDirectory
return $this->toDomainEntity($record);
}
#[Override]
#[\Override]
public function findLogs(
\DateTimeImmutable $since,
?\DateTimeImmutable $to = null,

View file

@ -7,7 +7,6 @@ namespace Infra\Casts;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
/** @implements CastsAttributes<SecurityAuditLogDetails, array<string, string>|SecurityAuditLogDetails|string|null> */
final class SecurityAuditLogDetailsCast implements CastsAttributes
@ -32,7 +31,7 @@ final class SecurityAuditLogDetailsCast implements CastsAttributes
}
if (! is_array($value)) {
throw new InvalidArgumentException('Details attribute must be an array, string, or null');
throw new \InvalidArgumentException('Details attribute must be an array, string, or null');
}
$values = [];
@ -63,9 +62,7 @@ final class SecurityAuditLogDetailsCast implements CastsAttributes
return null;
}
$json = json_encode($value->getValues(), JSON_UNESCAPED_SLASHES);
return $json !== false ? $json : null;
return $this->jsonEncode($value->getValues());
}
if (is_array($value)) {
@ -73,25 +70,45 @@ final class SecurityAuditLogDetailsCast implements CastsAttributes
return null;
}
$stringValues = [];
foreach ($value as $k => $v) {
$stringKey = (string) $k;
if (is_bool($v)) {
$stringValues[$stringKey] = $v ? 'true' : 'false';
} elseif (is_scalar($v)) {
$stringValues[$stringKey] = (string) $v;
}
}
$stringValues = $this->mapArray($value);
$json = json_encode($stringValues, JSON_UNESCAPED_SLASHES);
return $json !== false ? $json : null;
return $this->jsonEncode($stringValues);
}
if (is_string($value)) {
return $value;
}
throw new InvalidArgumentException('Value must be null, array, string, or instance of SecurityAuditLogDetails');
throw new \InvalidArgumentException('Value must be null, array, string, or instance of SecurityAuditLogDetails');
}
/**
* @param array<string, mixed> $array
* @return array<string, string>
*/
private function mapArray(array $array): array
{
$stringValues = [];
foreach ($array as $k => $v) {
$stringKey = (string) $k;
if (is_bool($v)) {
$stringValues[$stringKey] = $v ? 'true' : 'false';
} elseif (is_scalar($v)) {
$stringValues[$stringKey] = (string) $v;
}
}
return $stringValues;
}
/**
* @param array<string, string> $data
*/
private function jsonEncode(array $data): ?string
{
$json = json_encode($data, JSON_UNESCAPED_SLASHES);
return $json !== false ? $json : null;
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Infra\Config;
final readonly class ArcadiaStorageConfig
{
public string $cacheStore;
public string $databaseConnection;
public function __construct(string $cacheStore, string $databaseConnection)
{
$normalizedCache = trim($cacheStore);
if ($normalizedCache === '' || ! preg_match('/^[a-zA-Z0-9_\-]+$/', $normalizedCache)) {
throw new \InvalidArgumentException(sprintf('Invalid cache store name: "%s".', $cacheStore));
}
$normalizedDb = trim($databaseConnection);
if ($normalizedDb === '' || ! preg_match('/^[a-zA-Z0-9_\-]+$/', $normalizedDb)) {
throw new \InvalidArgumentException(sprintf('Invalid database connection name: "%s".', $databaseConnection));
}
$this->cacheStore = $normalizedCache;
$this->databaseConnection = $normalizedDb;
}
}

View file

@ -7,7 +7,7 @@ namespace Infra\Database\Models;
use Database\Factories\ArcadiaBrandFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use Infra\Config\ArcadiaStorageConfig;
/**
* @property int $code
@ -30,7 +30,7 @@ final class ArcadiaBrand extends Model
#[\Override]
public function getConnectionName(): string
{
return Config::string('database.arcadia_meta_model');
return resolve(ArcadiaStorageConfig::class)->databaseConnection;
}
protected $table = 'arcadia_brands';

View file

@ -7,7 +7,7 @@ namespace Infra\Database\Models;
use Database\Factories\EmployeeFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use Infra\Config\ArcadiaStorageConfig;
/**
* @property string $public_employee_number
@ -39,7 +39,7 @@ final class Employee extends Model
#[\Override]
public function getConnectionName(): string
{
return Config::string('database.arcadia_meta_model');
return resolve(ArcadiaStorageConfig::class)->databaseConnection;
}
protected $table = 'employees';

View file

@ -7,7 +7,7 @@ namespace Infra\Database\Models;
use Database\Factories\IncoCompoFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use Infra\Config\ArcadiaStorageConfig;
/**
* @property string $id
@ -46,7 +46,7 @@ final class IncoCompo extends Model
#[\Override]
public function getConnectionName(): string
{
return Config::string('database.arcadia_meta_model');
return resolve(ArcadiaStorageConfig::class)->databaseConnection;
}
protected $table = 'inco_compos';

View file

@ -7,7 +7,7 @@ namespace Infra\Database\Models;
use Database\Factories\IncoSheetFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use Infra\Config\ArcadiaStorageConfig;
/**
* @property string $id
@ -49,7 +49,7 @@ final class IncoSheet extends Model
#[\Override]
public function getConnectionName(): string
{
return Config::string('database.arcadia_meta_model');
return resolve(ArcadiaStorageConfig::class)->databaseConnection;
}
protected $table = 'inco_sheets';

View file

@ -4,11 +4,11 @@ declare(strict_types=1);
namespace Infra\Listeners;
use Application\Security\Config\SecurityAlertMailConfig;
use Application\Security\Events\ClientSecretGenerated;
use Application\Security\Events\ClientSecretRegenerated;
use Application\Security\Events\ClientStatusToggled;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailables\Address;
@ -23,7 +23,7 @@ final class SendClientSecurityAlertMail implements ShouldQueue
public function __construct(
private readonly Mailer $mailer,
private readonly ConfigRepository $config,
private readonly SecurityAlertMailConfig $config,
) {
}
@ -48,9 +48,7 @@ final class SendClientSecurityAlertMail implements ShouldQueue
private function getEventRecipient(): Address
{
$recipientValue = $this->config->get('mail.security_recipient', 'security@example.com');
return new Address(is_string($recipientValue) ? $recipientValue : 'security@example.com');
return new Address($this->config->recipientEmail->value);
}
private function getEventType(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): string

View file

@ -4,10 +4,11 @@ declare(strict_types=1);
namespace Infra\Providers;
use Application\Arcadia\Config\AppConfig;
use Application\Arcadia\Config\ThrottlingConfig;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request as IlluminateRequest;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Request;
@ -19,6 +20,7 @@ use Uri\Rfc3986\Uri;
class AppServiceProvider extends ServiceProvider
{
#[\Override]
public function register(): void
{
$this->app->singleton('clientIp', function () {
@ -26,9 +28,9 @@ class AppServiceProvider extends ServiceProvider
});
}
public function boot(): void
public function boot(AppConfig $appConfig, ThrottlingConfig $throttlingConfig): void
{
$this->configureRateLimiting();
$this->configureRateLimiting($throttlingConfig);
ini_set('assert.active', '1');
ini_set('assert.exception', '1');
@ -37,19 +39,20 @@ class AppServiceProvider extends ServiceProvider
URL::forceScheme('https');
$primaryLocale = $appConfig->locale->value;
foreach ([\LC_TIME, \LC_MESSAGES, \LC_COLLATE, \LC_CTYPE, \LC_MONETARY] as $constant) {
setlocale(
$constant,
sprintf(
'%s_%s.UTF-8',
Config::string('app.locale'),
strtoupper(Config::string('app.locale'))
$primaryLocale,
strtoupper($primaryLocale)
)
);
}
Date::setLocale(Config::string('app.locale'));
Date::setFallbackLocale(Config::string('app.fallback_locale'));
Date::setLocale($appConfig->locale->value);
Date::setFallbackLocale($appConfig->fallbackLocale->value);
Request::macro('pagination', function (int $defaultPageSize = 25): Pagination {
/** @var \Illuminate\Http\Request $this */
@ -67,14 +70,14 @@ class AppServiceProvider extends ServiceProvider
});
}
protected function configureRateLimiting(): void
protected function configureRateLimiting(ThrottlingConfig $throttlingConfig): void
{
RateLimiter::for('web', function (IlluminateRequest $_) {
return Limit::perMinute(Config::integer('throttles.web'))->by(resolve('clientIp'));
RateLimiter::for('web', function (IlluminateRequest $_) use ($throttlingConfig) {
return Limit::perMinute($throttlingConfig->webLimit->toInt())->by(resolve('clientIp'));
});
RateLimiter::for('api.v1', function (IlluminateRequest $_) {
return Limit::perMinute(Config::integer('throttles.api.v1'))->by(resolve('clientIp'));
RateLimiter::for('api.v1', function (IlluminateRequest $_) use ($throttlingConfig) {
return Limit::perMinute($throttlingConfig->apiV1Limit->toInt())->by(resolve('clientIp'));
});
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Infra\Providers;
use Application\Arcadia\Config\AppConfig;
use Application\Arcadia\Config\ThrottlingConfig;
use Application\Security\Config\DevClientCredentials;
use Application\Security\Config\HmacAuthConfig;
use Application\Security\Config\SecurityAlertMailConfig;
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 Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
use Infra\Config\ArcadiaStorageConfig;
use Ramsey\Uuid\Uuid;
use Shared\ValueObjects\Config\AppName;
use Shared\ValueObjects\Config\AppTimezone;
use Shared\ValueObjects\Config\Locale;
use Shared\ValueObjects\Config\RequestsPerMinute;
use Shared\ValueObjects\Email;
final class ConfigServiceProvider extends ServiceProvider
{
#[\Override]
public function register(): void
{
$this->app->bind(SecurityAlertMailConfig::class, static function (): SecurityAlertMailConfig {
return new SecurityAlertMailConfig(
recipientEmail: new Email(Config::string('mail.security_recipient', 'security@example.com')),
);
});
$this->app->bind(HmacAuthConfig::class, static function (): HmacAuthConfig {
$allowDevBypass = Config::boolean('auth.hmac.allow_dev_bypass', false);
$devClient = null;
if (Config::has('auth.hmac.dev_client')) {
/** @var array{id: string, name: string, identifier: string, secret: string} $devClientConfig */
$devClientConfig = Config::array('auth.hmac.dev_client');
$devClient = new DevClientCredentials(
id: new ClientId(Uuid::fromString($devClientConfig['id'])),
name: new ClientName($devClientConfig['name']),
identifier: new ClientIdentifier($devClientConfig['identifier']),
secret: new ClientSecret($devClientConfig['secret']),
);
}
return new HmacAuthConfig(
allowDevBypass: $allowDevBypass,
devClient: $devClient,
);
});
$this->app->bind(ThrottlingConfig::class, static function (): ThrottlingConfig {
return new ThrottlingConfig(
webLimit: new RequestsPerMinute(Config::integer('throttles.web', 120)),
apiV1Limit: new RequestsPerMinute(Config::integer('throttles.api.v1', 60)),
);
});
$this->app->bind(AppConfig::class, static function (): AppConfig {
return new AppConfig(
name: new AppName(Config::string('app.name', 'Arcadia')),
timezone: new AppTimezone(Config::string('app.timezone', 'UTC')),
locale: new Locale(Config::string('app.locale', 'en')),
fallbackLocale: new Locale(Config::string('app.fallback_locale', 'en')),
isDebug: Config::boolean('app.debug', false),
);
});
$this->app->bind(ArcadiaStorageConfig::class, static function (): ArcadiaStorageConfig {
return new ArcadiaStorageConfig(
cacheStore: Config::string('cache.arcadia_store', 'database'),
databaseConnection: Config::string('database.arcadia_meta_model', 'arcadia'),
);
});
}
}

View file

@ -11,8 +11,8 @@ use Domain\Ports\Directories\IncoCompositionDirectory;
use Domain\Ports\Directories\IncoSheetDirectory;
use Domain\Ports\Directories\IncoShortSheetDirectory;
use Domain\Ports\Repositories\BrandRepository;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
use Infra\Adapters\Directories\EloquentArcadiaBrandDirectory;
use Infra\Adapters\Directories\EloquentClientAuditLogDirectory;
@ -23,6 +23,7 @@ use Infra\Adapters\Directories\EloquentIncoShortSheetDirectory;
use Infra\Adapters\Events\LaravelTransactionalDomainEventDispatcher;
use Infra\Adapters\Repositories\EloquentBrandRepository;
use Infra\Cache\BrandCache;
use Infra\Config\ArcadiaStorageConfig;
use Infra\Usecase\LaravelExecutor;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Event\TransactionalDomainEventDispatcher;
@ -41,10 +42,9 @@ class DomainServiceProvider extends ServiceProvider
$this->app->bind(BrandRepository::class, EloquentBrandRepository::class);
$this->app->bind(ClientAuditLogDirectory::class, EloquentClientAuditLogDirectory::class);
$this->app->bind(BrandCache::class, static function (): BrandCache {
$storeName = Config::string('cache.arcadia_store');
/** @var \Psr\SimpleCache\CacheInterface $cache */
$cache = Cache::store($storeName);
$this->app->bind(BrandCache::class, static function (Application $app): BrandCache {
$storageConfig = $app->make(ArcadiaStorageConfig::class);
$cache = Cache::store($storageConfig->cacheStore);
return new BrandCache($cache);
});

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Infra\Providers;
use Application\Arcadia\Config\AppConfig;
use Application\Security\Ports\Directories\ClientDirectory;
use Application\Security\Ports\Repositories\ClientRepository;
use Application\Security\Ports\Services\ClientCredentialGenerator;
@ -13,7 +14,6 @@ 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;
@ -21,6 +21,7 @@ use Infra\Adapters\Services\ClientCredentialGeneratorImpl;
use Infra\Adapters\Services\HmacSignatureValidatorImpl;
use Infra\Adapters\Services\RequestValidatorImpl;
use Infra\Adapters\Services\TimestampValidatorImpl;
use Infra\Config\ArcadiaStorageConfig;
use Presentation\Api\V1\Security\ApiUserDetailsProvider;
use Presentation\Console\Security\ConsoleUserDetailsProvider;
use Psr\Log\LoggerInterface;
@ -37,9 +38,8 @@ final class SecurityServiceProvider extends ServiceProvider
$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);
$storageConfig = $app->make(ArcadiaStorageConfig::class);
$cache = Cache::store($storageConfig->cacheStore);
return new CachedNonceValidator(
logger: $app->make(LoggerInterface::class),
@ -47,11 +47,12 @@ final class SecurityServiceProvider extends ServiceProvider
);
});
$this->app->bind(TimestampValidator::class, function () {
$timezone = new \DateTimeZone(Config::string('app.timezone'));
$this->app->bind(TimestampValidator::class, function (Application $app): TimestampValidatorImpl {
$appConfig = $app->make(AppConfig::class);
return new TimestampValidatorImpl($timezone);
return new TimestampValidatorImpl($appConfig->timezone->toDateTimeZone());
});
$this->app->bind(RequestValidator::class, RequestValidatorImpl::class);
if ($this->app->runningInConsole()) {

View file

@ -7,7 +7,6 @@ namespace Presentation\Api\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Presentation\Api\V1\Exceptions\ApiExceptionRenderer as V1ApiExceptionRenderer;
use Throwable;
final readonly class ApiExceptionDispatcher
{
@ -16,7 +15,7 @@ final readonly class ApiExceptionDispatcher
) {
}
public function render(Throwable $e, Request $request): ?JsonResponse
public function render(\Throwable $e, Request $request): ?JsonResponse
{
if (! $request->is('api/*', 'api') && ! $request->wantsJson()) {
return null;

View file

@ -4,22 +4,26 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Exceptions;
use Application\Arcadia\Config\AppConfig;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Validation\ValidationException;
use Shared\DDD\Exception\NotFoundException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Throwable;
use Uri\Rfc3986\Uri;
final readonly class ApiExceptionRenderer
{
private const CONTENT_TYPE = 'application/problem+json';
public function render(Throwable $e, Request $request): JsonResponse
public function __construct(
private AppConfig $appConfig,
) {
}
public function render(\Throwable $e, Request $request): JsonResponse
{
$baseUri = $request->toUri();
@ -84,7 +88,7 @@ final readonly class ApiExceptionRenderer
};
}
private function renderDefault(Throwable $e, Request $request, Uri $baseUri): JsonResponse
private function renderDefault(\Throwable $e, Request $request, Uri $baseUri): JsonResponse
{
$status = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500;
$title = Response::$statusTexts[$status] ?? 'Internal Server Error';
@ -92,7 +96,7 @@ final readonly class ApiExceptionRenderer
$detail = $title;
if ($e instanceof HttpExceptionInterface && $e->getMessage() !== '') {
$detail = $e->getMessage();
} elseif (Config::boolean('app.debug')) {
} elseif ($this->appConfig->isDebug) {
$detail = $e->getMessage() ?: $title;
}

View file

@ -4,16 +4,15 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Http\Middlewares;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureApiV1AcceptsJson
{
/**
* @param Closure(Request): Response $next
* @param \Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
public function handle(Request $request, \Closure $next): Response
{
// * Bypass for OpenAPI YAML spec route
if ($request->routeIs('api.v1.openapi')) {

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Http\Middlewares;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
@ -12,9 +11,9 @@ use Symfony\Component\HttpFoundation\Response;
final class SetApiV1Etag
{
/**
* @param Closure(Request): Response $next
* @param \Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
public function handle(Request $request, \Closure $next): Response
{
/** @var Response $response */
$response = $next($request);

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Http\Middlewares;
use Closure;
use Illuminate\Http\Request;
use Presentation\Api\V1\Contracts\ApiResponse;
use Symfony\Component\HttpFoundation\Response;
@ -12,9 +11,9 @@ use Symfony\Component\HttpFoundation\Response;
final class TransformApiV1Response
{
/**
* @param Closure(Request): Response $next
* @param \Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
public function handle(Request $request, \Closure $next): Response
{
$response = $next($request);

View file

@ -4,10 +4,8 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Http\Middlewares;
use Application\Security\Config\HmacAuthConfig;
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\HttpBodyHash;
use Application\Security\Models\ValueObjects\Request\HttpMethod;
use Application\Security\Models\ValueObjects\Request\Nonce;
@ -15,10 +13,8 @@ use Application\Security\Models\ValueObjects\Request\Request as SecurityRequestV
use Application\Security\Models\ValueObjects\Request\RequestSignature;
use Application\Security\Models\ValueObjects\Request\RequestTimestamp;
use Application\Security\Ports\Services\RequestValidator;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Dto\ApiClientUserDetails;
use Presentation\Api\V1\Security\ApiUserDetailsProvider;
@ -41,13 +37,14 @@ final readonly class VerifyHmacSignature
private LoggerInterface $logger,
private RequestValidator $requestValidator,
private ApiUserDetailsProvider $userDetailsProvider,
private HmacAuthConfig $config,
) {
}
/**
* @param Closure(Request): Response $next
* @param \Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
public function handle(Request $request, \Closure $next): Response
{
if ($this->shouldBypassHmac()) {
return $this->handleBypassedRequest($request, $next);
@ -102,27 +99,29 @@ final readonly class VerifyHmacSignature
return false;
}
return Config::boolean('auth.hmac.allow_dev_bypass', false);
return $this->config->allowDevBypass;
}
/**
* @param Closure(Request): Response $next
* @param \Closure(Request): Response $next
*/
private function handleBypassedRequest(Request $request, Closure $next): Response
private function handleBypassedRequest(Request $request, \Closure $next): Response
{
$this->logger->debug('HMAC verification bypassed in local/testing environment.', [
'requestUri' => $request->getUri(),
'requestMethod' => $request->getMethod(),
]);
/** @var array{id: string, name: string, identifier: string, secret: string} $devClientConfig */
$devClientConfig = Config::array('auth.hmac.dev_client');
$devClient = $this->config->devClient;
if ($devClient === null) {
throw new \LogicException('Dev client credentials must be configured when HMAC dev bypass is enabled.');
}
$devUserDetails = new ApiClientUserDetails(
clientId: new ClientId(Uuid::fromString($devClientConfig['id'])),
clientIdentifier: new ClientIdentifier($devClientConfig['identifier']),
clientName: new ClientName($devClientConfig['name']),
clientSecret: new ClientSecret($devClientConfig['secret']),
clientId: $devClient->id,
clientIdentifier: $devClient->identifier,
clientName: $devClient->name,
clientSecret: $devClient->secret,
);
$this->userDetailsProvider->setCurrentUser($devUserDetails);

View file

@ -7,11 +7,10 @@ namespace Presentation\Web\Exceptions;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Shared\DDD\Exception\NotFoundException;
use Throwable;
final readonly class WebExceptionRenderer
{
public function render(Throwable $e, Request $request): ?Response
public function render(\Throwable $e, Request $request): ?Response
{
if ($request->is('api/*', 'api') || $request->wantsJson()) {
return null;

View file

@ -1,5 +1,5 @@
@extends('layouts.minimal')
@section('title', Config::string('app.name'))
@section('title', resolve(\Application\Arcadia\Config\AppConfig::class)->name->value)
@section('code', 'API')
@section('message', 'Arcadia API is up and running')

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shared\DDD\Usecase\Builder;
use LogicException;
use Shared\DDD\Usecase\Input;
use Shared\DDD\Usecase\Presenter;
use Shared\DDD\Usecase\Usecase;
@ -52,7 +51,7 @@ final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInpu
public function from(Input $input): BuilderStepPresenter
{
if ($this->usecase === null) {
throw new LogicException('Usecase must be set before input.');
throw new \LogicException('Usecase must be set before input.');
}
$this->input = $input;
@ -70,7 +69,7 @@ final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInpu
public function with(Presenter $presenter): BuilderStepBuild
{
if ($this->usecase === null || $this->input === null) {
throw new LogicException('Usecase and input must be set before presenter.');
throw new \LogicException('Usecase and input must be set before presenter.');
}
/** @var Presenter<\Shared\DDD\Usecase\Output, object> $presenter */
@ -84,7 +83,7 @@ final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInpu
public function run(): object
{
if ($this->usecase === null || $this->input === null) {
throw new LogicException('Usecase and input are required.');
throw new \LogicException('Usecase and input are required.');
}
/** @var TView */

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shared\ValueObjects\Config;
use Shared\DDD\Model\Equatable;
final readonly class AppName implements \Stringable, Equatable
{
public string $value;
public function __construct(string $value)
{
$normalized = trim($value);
if ($normalized === '') {
throw new \InvalidArgumentException('Application name cannot be empty.');
}
$this->value = $normalized;
}
#[\Override]
public function __toString(): string
{
return $this->value;
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self
&& $this->value === $other->value;
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Shared\ValueObjects\Config;
use Shared\DDD\Model\Equatable;
final readonly class AppTimezone implements \Stringable, Equatable
{
public string $name;
public function __construct(string $name)
{
$trimmed = trim($name);
try {
$tz = new \DateTimeZone($trimmed);
$this->name = $tz->getName();
} catch (\Exception) {
throw new \InvalidArgumentException(sprintf('Invalid timezone identifier: "%s".', $name));
}
}
public static function utc(): self
{
return new self('UTC');
}
public function toDateTimeZone(): \DateTimeZone
{
return new \DateTimeZone($this->name);
}
#[\Override]
public function __toString(): string
{
return $this->name;
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self
&& $this->name === $other->name;
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Shared\ValueObjects\Config;
use Shared\DDD\Model\Equatable;
final readonly class Locale implements \Stringable, Equatable
{
public string $value;
public function __construct(string $value)
{
$normalized = trim($value);
if (! preg_match('/^[a-zA-Z]{2}(?:[_\-][a-zA-Z0-9]{2,})?$/', $normalized)) {
throw new \InvalidArgumentException(sprintf('Invalid locale format: "%s".', $value));
}
$this->value = $normalized;
}
public function value(): string
{
return $this->value;
}
public function primaryLanguage(): string
{
$parts = preg_split('/[_\-]/', $this->value);
return $parts !== false && isset($parts[0]) ? strtolower($parts[0]) : $this->value;
}
#[\Override]
public function __toString(): string
{
return $this->value;
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self
&& $this->value === $other->value;
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Shared\ValueObjects\Config;
use Shared\DDD\Model\Equatable;
final readonly class RequestsPerMinute implements \Stringable, Equatable
{
public function __construct(
public int $value,
) {
if ($this->value < 1) {
throw new \InvalidArgumentException(sprintf('Requests per minute must be at least 1, got %d.', $this->value));
}
}
public function toInt(): int
{
return $this->value;
}
public function isGreaterThan(self $other): bool
{
return $this->value > $other->value;
}
#[\Override]
public function __toString(): string
{
return (string) $this->value;
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self
&& $this->value === $other->value;
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shared\ValueObjects;
use Shared\DDD\Model\Equatable;
final readonly class Email implements \Stringable, Equatable
{
public string $value;
public function __construct(string $value)
{
$normalized = trim($value);
if (! filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException(sprintf('Invalid email address: "%s".', $value));
}
$this->value = mb_strtolower($normalized, 'UTF-8');
}
#[\Override]
public function __toString(): string
{
return $this->value;
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self
&& $this->value === $other->value;
}
}

View file

@ -111,13 +111,25 @@ describe('Coding Standards & Project Rules', function (): void {
}
});
it('ensures every Unit and Integration test file contains a covers() call', function (): void {
foreach (getTestFiles(['tests/Unit', 'tests/Integration']) as $file) {
$content = (string) file_get_contents($file);
expect($content)->toMatch(
'/^\s*covers\s*\(/m',
"Test file [{$file}] is missing a covers(...) declaration."
);
it('ensures native classes in global namespace are not imported with use statements in src', function (): void {
$srcDir = dirname(__DIR__, 2) . '/src';
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($srcDir, \RecursiveDirectoryIterator::SKIP_DOTS));
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$content = (string) file_get_contents($file->getPathname());
if (preg_match('/^use\s+([A-Z][a-zA-Z0-9_]*)\s*;/m', $content, $matches)) {
$className = $matches[1];
if (class_exists('\\' . $className) || interface_exists('\\' . $className)) {
expect(true)->toBeFalse(
sprintf('File [%s] imports global native class [%s] with a use statement. Use [\\%s] directly instead.', $file->getPathname(), $className, $className)
);
}
}
}
}
expect(true)->toBeTrue();
});
});

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Tests\Integration\Infra\Providers;
use Application\Arcadia\Config\AppConfig;
use Application\Arcadia\Config\ThrottlingConfig;
use Application\Security\Config\HmacAuthConfig;
use Application\Security\Config\SecurityAlertMailConfig;
use Infra\Config\ArcadiaStorageConfig;
use Infra\Providers\ConfigServiceProvider;
use Tests\TestCase;
uses(TestCase::class);
covers(ConfigServiceProvider::class);
describe('ConfigServiceProvider Integration Tests', function (): void {
it('resolves SecurityAlertMailConfig with typed Email VO', function (): void {
// WHEN
$config = resolve(SecurityAlertMailConfig::class);
// THEN
expect($config)->toBeInstanceOf(SecurityAlertMailConfig::class)
->and($config->recipientEmail->value)->not->toBeEmpty();
});
it('resolves HmacAuthConfig with dev client credentials', function (): void {
// WHEN
$config = resolve(HmacAuthConfig::class);
// THEN
expect($config)->toBeInstanceOf(HmacAuthConfig::class)
->and($config->devClient)->not->toBeNull()
->and($config->devClient?->name->value)->toBe('Arcadia Dev Test Client');
});
it('resolves ThrottlingConfig with typed RequestsPerMinute VOs', function (): void {
// WHEN
$config = resolve(ThrottlingConfig::class);
// THEN
expect($config)->toBeInstanceOf(ThrottlingConfig::class)
->and($config->webLimit->toInt())->toBeGreaterThanOrEqual(1)
->and($config->apiV1Limit->toInt())->toBeGreaterThanOrEqual(1);
});
it('resolves AppConfig with typed timezone and locale VOs', function (): void {
// WHEN
$config = resolve(AppConfig::class);
// THEN
expect($config)->toBeInstanceOf(AppConfig::class)
->and($config->name->value)->not->toBeEmpty()
->and($config->timezone->name)->toBe('UTC')
->and($config->locale->value())->not->toBeEmpty()
->and($config->fallbackLocale->value())->not->toBeEmpty();
});
it('resolves ArcadiaStorageConfig with database and cache settings', function (): void {
// WHEN
$config = resolve(ArcadiaStorageConfig::class);
// THEN
expect($config)->toBeInstanceOf(ArcadiaStorageConfig::class)
->and($config->cacheStore)->not->toBeEmpty()
->and($config->databaseConnection)->not->toBeEmpty();
});
});

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Integration\Presentation\Api;
use Application\Arcadia\Config\AppConfig;
use Illuminate\Http\Request;
use Presentation\Api\Exceptions\ApiExceptionDispatcher;
use Presentation\Api\V1\Exceptions\ApiExceptionRenderer as V1ApiExceptionRenderer;
@ -14,7 +15,9 @@ covers(ApiExceptionDispatcher::class);
describe('API Exception Dispatcher Integration', function (): void {
it('returns null for non-API web requests that do not accept JSON', function (): void {
// GIVEN
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
$config = resolve(AppConfig::class);
$renderer = new V1ApiExceptionRenderer($config);
$dispatcher = new ApiExceptionDispatcher($renderer);
$request = Request::create('/some-web-route', 'GET');
$exception = new NotFoundException('Not found');
@ -27,7 +30,9 @@ describe('API Exception Dispatcher Integration', function (): void {
it('dispatches api/v1 requests to V1ApiExceptionRenderer', function (): void {
// GIVEN
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
$config = resolve(AppConfig::class);
$renderer = new V1ApiExceptionRenderer($config);
$dispatcher = new ApiExceptionDispatcher($renderer);
$request = Request::create('http://localhost/api/v1/employees/999', 'GET');
$exception = new NotFoundException('Employee not found');
@ -38,44 +43,41 @@ describe('API Exception Dispatcher Integration', function (): void {
expect($response)->not->toBeNull()
->and($response?->getStatusCode())->toBe(404)
->and($response?->headers->get('Content-Type'))->toBe('application/problem+json');
/** @var array<string, mixed> $data */
$data = json_decode((string) $response?->getContent(), true);
expect($data['status'])->toBe(404)
->and($data['title'])->toBe('Resource Not Found')
->and($data['detail'])->toBe('Employee not found');
});
it('falls back to V1ApiExceptionRenderer for unversioned api requests', function (): void {
// GIVEN
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
$request = Request::create('http://localhost/api/ping', 'GET');
$exception = new \DomainException('Domain invariant error');
$config = resolve(AppConfig::class);
$renderer = new V1ApiExceptionRenderer($config);
$dispatcher = new ApiExceptionDispatcher($renderer);
$request = Request::create('http://localhost/api/unknown-endpoint', 'GET');
$request->headers->set('Accept', 'application/json');
$exception = new NotFoundException('Resource not found');
// WHEN
$response = $dispatcher->render($exception, $request);
// THEN
expect($response)->not->toBeNull()
->and($response?->getStatusCode())->toBe(422);
/** @var array<string, mixed> $data */
$data = json_decode((string) $response?->getContent(), true);
expect($data['status'])->toBe(422)
->and($data['detail'])->toBe('Domain invariant error');
->and($response?->getStatusCode())->toBe(404)
->and($response?->headers->get('Content-Type'))->toBe('application/problem+json');
});
it('dispatches to V1ApiExceptionRenderer when request accepts JSON even on non-api path', function (): void {
// GIVEN
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
$request = Request::create('http://localhost/custom-endpoint', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']);
$exception = new NotFoundException('Custom endpoint not found');
$config = resolve(AppConfig::class);
$renderer = new V1ApiExceptionRenderer($config);
$dispatcher = new ApiExceptionDispatcher($renderer);
$request = Request::create('http://localhost/something-else', 'GET');
$request->headers->set('Accept', 'application/json');
$exception = new NotFoundException('Something not found');
// WHEN
$response = $dispatcher->render($exception, $request);
// THEN
expect($response)->not->toBeNull()
->and($response?->getStatusCode())->toBe(404);
->and($response?->getStatusCode())->toBe(404)
->and($response?->headers->get('Content-Type'))->toBe('application/problem+json');
});
});

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Integration\Presentation\Api\V1;
use Application\Arcadia\Config\AppConfig;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
@ -17,7 +18,8 @@ covers(ApiExceptionRenderer::class);
describe('V1 API Exception Renderer Integration', function (): void {
it('renders NotFoundException as 404 Problem Details JSON', function (): void {
// GIVEN
$renderer = new ApiExceptionRenderer();
$config = resolve(AppConfig::class);
$renderer = new ApiExceptionRenderer($config);
$request = Request::create('http://localhost/api/v1/employees/999', 'GET');
$exception = new NotFoundException('Employee with ID 999 not found.');
@ -39,7 +41,8 @@ describe('V1 API Exception Renderer Integration', function (): void {
it('renders ValidationException as 422 Problem Details JSON with invalidParams', function (): void {
// GIVEN
$renderer = new ApiExceptionRenderer();
$config = resolve(AppConfig::class);
$renderer = new ApiExceptionRenderer($config);
$request = Request::create('http://localhost/api/v1/employees', 'POST');
$validator = validator(['email' => 'invalid'], ['email' => 'email|required']);
@ -64,7 +67,8 @@ describe('V1 API Exception Renderer Integration', function (): void {
it('renders DomainException as 422 Problem Details JSON', function (): void {
// GIVEN
$renderer = new ApiExceptionRenderer();
$config = resolve(AppConfig::class);
$renderer = new ApiExceptionRenderer($config);
$request = Request::create('http://localhost/api/v1/employees', 'POST');
$exception = new \DomainException('Invalid employee number format');
@ -85,7 +89,8 @@ describe('V1 API Exception Renderer Integration', function (): void {
it('renders ThrottleRequestsException as 429 Problem Details JSON with rate headers', function (): void {
// GIVEN
$renderer = new ApiExceptionRenderer();
$config = resolve(AppConfig::class);
$renderer = new ApiExceptionRenderer($config);
$request = Request::create('http://localhost/api/v1/employees', 'GET');
$exception = new ThrottleRequestsException('Too Many Attempts.', null, ['Retry-After' => '60']);
@ -105,7 +110,8 @@ describe('V1 API Exception Renderer Integration', function (): void {
it('renders HttpExceptionInterface exception with status code and detail', function (): void {
// GIVEN
$renderer = new ApiExceptionRenderer();
$config = resolve(AppConfig::class);
$renderer = new ApiExceptionRenderer($config);
$request = Request::create('http://localhost/api/v1/employees', 'DELETE');
$exception = new AccessDeniedHttpException('Access denied');
@ -126,7 +132,8 @@ describe('V1 API Exception Renderer Integration', function (): void {
it('renders generic Throwable as 500 Problem Details JSON with debug info based on config', function (): void {
// GIVEN
Config::set('app.debug', true);
$renderer = new ApiExceptionRenderer();
$config = resolve(AppConfig::class);
$renderer = new ApiExceptionRenderer($config);
$request = Request::create('http://localhost/api/v1/employees', 'GET');
$exception = new \RuntimeException('Database connection failure');

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Arcadia\Config;
use Application\Arcadia\Config\AppConfig;
use Application\Arcadia\Config\ThrottlingConfig;
use Infra\Config\ArcadiaStorageConfig;
use Shared\ValueObjects\Config\AppName;
use Shared\ValueObjects\Config\AppTimezone;
use Shared\ValueObjects\Config\Locale;
use Shared\ValueObjects\Config\RequestsPerMinute;
covers(
ThrottlingConfig::class,
AppConfig::class,
ArcadiaStorageConfig::class,
);
describe('Arcadia Config Objects Unit Tests', function (): void {
it('instantiates ThrottlingConfig with typed RequestsPerMinute VOs', function (): void {
// GIVEN
$webLimit = new RequestsPerMinute(120);
$apiV1Limit = new RequestsPerMinute(60);
// WHEN
$config = new ThrottlingConfig(
webLimit: $webLimit,
apiV1Limit: $apiV1Limit,
);
// THEN
expect($config->webLimit->toInt())->toBe(120)
->and($config->apiV1Limit->toInt())->toBe(60);
});
it('instantiates AppConfig with typed name, timezone and locale VOs', function (): void {
// GIVEN
$config = new AppConfig(
name: new AppName('Arcadia API'),
timezone: AppTimezone::utc(),
locale: new Locale('fr'),
fallbackLocale: new Locale('en'),
isDebug: true,
);
// THEN
expect($config->name->value)->toBe('Arcadia API')
->and($config->timezone->name)->toBe('UTC')
->and($config->locale->value())->toBe('fr')
->and($config->fallbackLocale->value())->toBe('en')
->and($config->isDebug)->toBeTrue();
});
it('instantiates ArcadiaStorageConfig with valid connection and store names', function (): void {
// GIVEN
$config = new ArcadiaStorageConfig(
cacheStore: 'redis',
databaseConnection: 'arcadia_custom',
);
// THEN
expect($config->cacheStore)->toBe('redis')
->and($config->databaseConnection)->toBe('arcadia_custom');
});
it('rejects invalid cache store in ArcadiaStorageConfig', function (string $invalidStore): void {
// GIVEN & WHEN & THEN
expect(fn () => new ArcadiaStorageConfig(
cacheStore: $invalidStore,
databaseConnection: 'arcadia',
))->toThrow(\InvalidArgumentException::class);
})->with([
'',
' ',
'invalid store name with spaces',
'invalid$store',
]);
it('rejects invalid database connection in ArcadiaStorageConfig', function (string $invalidDb): void {
// GIVEN & WHEN & THEN
expect(fn () => new ArcadiaStorageConfig(
cacheStore: 'redis',
databaseConnection: $invalidDb,
))->toThrow(\InvalidArgumentException::class);
})->with([
'',
' ',
'invalid connection with spaces',
'invalid;connection',
]);
});

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\Config;
use Application\Security\Config\DevClientCredentials;
use Application\Security\Config\HmacAuthConfig;
use Application\Security\Config\SecurityAlertMailConfig;
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 Ramsey\Uuid\Uuid;
use Shared\ValueObjects\Email;
covers(
DevClientCredentials::class,
HmacAuthConfig::class,
SecurityAlertMailConfig::class,
);
describe('Security Config Objects Unit Tests', function (): void {
it('instantiates DevClientCredentials and HmacAuthConfig', function (): void {
// GIVEN
$id = new ClientId(Uuid::uuid7());
$name = new ClientName('Test Dev Client');
$identifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32));
$secret = new ClientSecret('arcadia_sec_' . str_repeat('b', 88));
$credentials = new DevClientCredentials(
id: $id,
name: $name,
identifier: $identifier,
secret: $secret,
);
$hmacConfig = new HmacAuthConfig(
allowDevBypass: true,
devClient: $credentials,
);
// THEN
expect($hmacConfig->allowDevBypass)->toBeTrue()
->and($hmacConfig->devClient)->toBe($credentials)
->and($hmacConfig->devClient?->name->value)->toBe('Test Dev Client')
->and($hmacConfig->devClient?->identifier->value)->toBe($identifier->value);
});
it('instantiates SecurityAlertMailConfig with typed Email VO', function (): void {
// GIVEN
$email = new Email('security-team@arcadia.com');
$config = new SecurityAlertMailConfig($email);
// THEN
expect($config->recipientEmail->value)->toBe('security-team@arcadia.com');
});
});

View file

@ -4,16 +4,17 @@ declare(strict_types=1);
namespace Tests\Unit\Infra\Listeners;
use Application\Security\Config\SecurityAlertMailConfig;
use Application\Security\Events\ClientSecretGenerated;
use Application\Security\Events\ClientSecretRegenerated;
use Application\Security\Events\ClientStatusToggled;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Support\Facades\Mail;
use Infra\Listeners\SendClientSecurityAlertMail;
use Infra\Mail\ClientSecurityAlertMail;
use Ramsey\Uuid\Uuid;
use Shared\ValueObjects\Email;
use Tests\TestCase;
uses(TestCase::class);
@ -36,8 +37,8 @@ describe('SendClientSecurityAlertMail Listener Unit Tests', function (): void {
generatedAt: $generatedAt,
);
$configMock = resolve(ConfigRepository::class);
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $configMock);
$config = new SecurityAlertMailConfig(new Email('security@example.com'));
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $config);
// WHEN
$listener->handle($event);
@ -61,8 +62,8 @@ describe('SendClientSecurityAlertMail Listener Unit Tests', function (): void {
generatedAt: $regeneratedAt,
);
$configMock = resolve(ConfigRepository::class);
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $configMock);
$config = new SecurityAlertMailConfig(new Email('security@example.com'));
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $config);
// WHEN
$listener->handle($event);
@ -87,8 +88,8 @@ describe('SendClientSecurityAlertMail Listener Unit Tests', function (): void {
toggledAt: $toggledAt,
);
$configMock = resolve(ConfigRepository::class);
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $configMock);
$config = new SecurityAlertMailConfig(new Email('security@example.com'));
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $config);
// WHEN
$listener->handle($event);

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\ValueObjects\Config;
use Shared\ValueObjects\Config\AppName;
covers(AppName::class);
describe('AppName Value Object Unit Tests', function (): void {
it('creates valid application name', function (): void {
// GIVEN
$name = new AppName(' Arcadia API ');
// THEN
expect($name->value)->toBe('Arcadia API')
->and((string) $name)->toBe('Arcadia API');
});
it('rejects empty application name', function (string $invalid): void {
// GIVEN & WHEN & THEN
expect(fn () => new AppName($invalid))
->toThrow(\InvalidArgumentException::class);
})->with([
'',
' ',
"\t\n",
]);
it('handles equality properly', function (): void {
// GIVEN
$name1 = new AppName('Arcadia API');
$name2 = new AppName('Arcadia API');
$name3 = new AppName('Other API');
// THEN
expect($name1->equals($name2))->toBeTrue()
->and($name1->equals($name3))->toBeFalse()
->and($name1->equals('Arcadia API'))->toBeFalse();
});
});

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\ValueObjects\Config;
use Shared\ValueObjects\Config\AppTimezone;
covers(AppTimezone::class);
describe('AppTimezone Value Object Unit Tests', function (): void {
it('creates valid timezone from name', function (): void {
// GIVEN
$tz = new AppTimezone('Europe/Paris');
// THEN
expect($tz->name)->toBe('Europe/Paris')
->and((string) $tz)->toBe('Europe/Paris')
->and($tz->toDateTimeZone())->toBeInstanceOf(\DateTimeZone::class)
->and($tz->toDateTimeZone()->getName())->toBe('Europe/Paris');
});
it('creates default UTC timezone', function (): void {
// GIVEN
$tz = AppTimezone::utc();
// THEN
expect($tz->name)->toBe('UTC')
->and((string) $tz)->toBe('UTC');
});
it('rejects invalid timezone names', function (string $invalid): void {
// GIVEN & WHEN & THEN
expect(fn () => new AppTimezone($invalid))
->toThrow(\InvalidArgumentException::class);
})->with([
'',
' ',
'Invalid/Timezone_Name',
'Moon/Base',
]);
it('handles equality properly', function (): void {
// GIVEN
$tz1 = new AppTimezone('UTC');
$tz2 = AppTimezone::utc();
$tz3 = new AppTimezone('Europe/Paris');
// THEN
expect($tz1->equals($tz2))->toBeTrue()
->and($tz1->equals($tz3))->toBeFalse()
->and($tz1->equals('UTC'))->toBeFalse();
});
});

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\ValueObjects\Config;
use Shared\ValueObjects\Config\Locale;
covers(Locale::class);
describe('Locale Value Object Unit Tests', function (): void {
it('creates valid locale', function (string $raw, string $expectedPrimary): void {
// WHEN
$locale = new Locale($raw);
// THEN
expect($locale->value())->toBe(trim($raw))
->and((string) $locale)->toBe(trim($raw))
->and($locale->primaryLanguage())->toBe($expectedPrimary);
})->with([
['fr', 'fr'],
['en', 'en'],
['fr_FR', 'fr'],
['en_US', 'en'],
['en-GB', 'en'],
]);
it('rejects invalid locale formats', function (string $invalid): void {
// GIVEN & WHEN & THEN
expect(fn () => new Locale($invalid))
->toThrow(\InvalidArgumentException::class);
})->with([
'',
' ',
'f',
'123',
'invalid_locale_format_too_long',
]);
it('handles equality properly', function (): void {
// GIVEN
$locale1 = new Locale('fr');
$locale2 = new Locale('fr');
$locale3 = new Locale('en');
// THEN
expect($locale1->equals($locale2))->toBeTrue()
->and($locale1->equals($locale3))->toBeFalse()
->and($locale1->equals('fr'))->toBeFalse();
});
});

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\ValueObjects\Config;
use Shared\ValueObjects\Config\RequestsPerMinute;
covers(RequestsPerMinute::class);
describe('RequestsPerMinute Value Object Unit Tests', function (): void {
it('creates valid requests per minute', function (): void {
// GIVEN
$rate = new RequestsPerMinute(60);
// THEN
expect($rate->value)->toBe(60)
->and($rate->toInt())->toBe(60)
->and((string) $rate)->toBe('60');
});
it('rejects values less than 1', function (int $invalid): void {
// GIVEN & WHEN & THEN
expect(fn () => new RequestsPerMinute($invalid))
->toThrow(\InvalidArgumentException::class);
})->with([0, -1, -100]);
it('compares with another RequestsPerMinute instance', function (): void {
// GIVEN
$rate60 = new RequestsPerMinute(60);
$rate120 = new RequestsPerMinute(120);
// THEN
expect($rate120->isGreaterThan($rate60))->toBeTrue()
->and($rate60->isGreaterThan($rate120))->toBeFalse()
->and($rate60->equals(new RequestsPerMinute(60)))->toBeTrue()
->and($rate60->equals($rate120))->toBeFalse();
});
});

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\ValueObjects;
use Shared\ValueObjects\Email;
covers(Email::class);
describe('Email Value Object Unit Tests', function (): void {
it('creates a valid normalized email', function (): void {
// GIVEN
$rawEmail = ' Admin.Security@Example.COM ';
// WHEN
$email = new Email($rawEmail);
// THEN
expect($email->value)->toBe('admin.security@example.com')
->and((string) $email)->toBe('admin.security@example.com');
});
it('rejects invalid email formats', function (string $invalid): void {
// GIVEN & WHEN & THEN
expect(fn () => new Email($invalid))
->toThrow(\InvalidArgumentException::class);
})->with([
'',
' ',
'not-an-email',
'@example.com',
'admin@',
'admin@.com',
'admin@com',
]);
it('correctly checks equality', function (): void {
// GIVEN
$email1 = new Email('test@example.com');
$email2 = new Email('TEST@EXAMPLE.COM');
$email3 = new Email('other@example.com');
// THEN
expect($email1->equals($email2))->toBeTrue()
->and($email1->equals($email3))->toBeFalse()
->and($email1->equals('test@example.com'))->toBeFalse();
});
});