diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 7112e74..aae2b36 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -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, diff --git a/src/Application/Arcadia/Config/AppConfig.php b/src/Application/Arcadia/Config/AppConfig.php new file mode 100644 index 0000000..bcbef9b --- /dev/null +++ b/src/Application/Arcadia/Config/AppConfig.php @@ -0,0 +1,21 @@ +toDomainEntity($record); } - #[Override] + #[\Override] public function findLogs( \DateTimeImmutable $since, ?\DateTimeImmutable $to = null, diff --git a/src/Infra/Casts/SecurityAuditLogDetailsCast.php b/src/Infra/Casts/SecurityAuditLogDetailsCast.php index 8f2b619..6e3ff32 100644 --- a/src/Infra/Casts/SecurityAuditLogDetailsCast.php +++ b/src/Infra/Casts/SecurityAuditLogDetailsCast.php @@ -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|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 $array + * @return array + */ + 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 $data + */ + private function jsonEncode(array $data): ?string + { + $json = json_encode($data, JSON_UNESCAPED_SLASHES); + + return $json !== false ? $json : null; } } diff --git a/src/Infra/Config/ArcadiaStorageConfig.php b/src/Infra/Config/ArcadiaStorageConfig.php new file mode 100644 index 0000000..9400b75 --- /dev/null +++ b/src/Infra/Config/ArcadiaStorageConfig.php @@ -0,0 +1,28 @@ +cacheStore = $normalizedCache; + $this->databaseConnection = $normalizedDb; + } +} diff --git a/src/Infra/Database/Models/ArcadiaBrand.php b/src/Infra/Database/Models/ArcadiaBrand.php index 52ca2df..55487ff 100644 --- a/src/Infra/Database/Models/ArcadiaBrand.php +++ b/src/Infra/Database/Models/ArcadiaBrand.php @@ -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'; diff --git a/src/Infra/Database/Models/Employee.php b/src/Infra/Database/Models/Employee.php index 7474c84..01a19b9 100644 --- a/src/Infra/Database/Models/Employee.php +++ b/src/Infra/Database/Models/Employee.php @@ -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'; diff --git a/src/Infra/Database/Models/IncoCompo.php b/src/Infra/Database/Models/IncoCompo.php index 68b6554..4e99481 100644 --- a/src/Infra/Database/Models/IncoCompo.php +++ b/src/Infra/Database/Models/IncoCompo.php @@ -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'; diff --git a/src/Infra/Database/Models/IncoSheet.php b/src/Infra/Database/Models/IncoSheet.php index a0735cc..da2371c 100644 --- a/src/Infra/Database/Models/IncoSheet.php +++ b/src/Infra/Database/Models/IncoSheet.php @@ -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'; diff --git a/src/Infra/Listeners/SendClientSecurityAlertMail.php b/src/Infra/Listeners/SendClientSecurityAlertMail.php index 50f838f..9210595 100644 --- a/src/Infra/Listeners/SendClientSecurityAlertMail.php +++ b/src/Infra/Listeners/SendClientSecurityAlertMail.php @@ -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 diff --git a/src/Infra/Providers/AppServiceProvider.php b/src/Infra/Providers/AppServiceProvider.php index a86bdaa..8a16901 100644 --- a/src/Infra/Providers/AppServiceProvider.php +++ b/src/Infra/Providers/AppServiceProvider.php @@ -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')); }); } } diff --git a/src/Infra/Providers/ConfigServiceProvider.php b/src/Infra/Providers/ConfigServiceProvider.php new file mode 100644 index 0000000..42528e1 --- /dev/null +++ b/src/Infra/Providers/ConfigServiceProvider.php @@ -0,0 +1,82 @@ +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'), + ); + }); + } +} diff --git a/src/Infra/Providers/DomainServiceProvider.php b/src/Infra/Providers/DomainServiceProvider.php index b12eb09..44eb7e6 100644 --- a/src/Infra/Providers/DomainServiceProvider.php +++ b/src/Infra/Providers/DomainServiceProvider.php @@ -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); }); diff --git a/src/Infra/Providers/SecurityServiceProvider.php b/src/Infra/Providers/SecurityServiceProvider.php index 3f9ef42..3c5bb90 100644 --- a/src/Infra/Providers/SecurityServiceProvider.php +++ b/src/Infra/Providers/SecurityServiceProvider.php @@ -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()) { diff --git a/src/Presentation/Api/Exceptions/ApiExceptionDispatcher.php b/src/Presentation/Api/Exceptions/ApiExceptionDispatcher.php index e2460ee..6075379 100644 --- a/src/Presentation/Api/Exceptions/ApiExceptionDispatcher.php +++ b/src/Presentation/Api/Exceptions/ApiExceptionDispatcher.php @@ -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; diff --git a/src/Presentation/Api/V1/Exceptions/ApiExceptionRenderer.php b/src/Presentation/Api/V1/Exceptions/ApiExceptionRenderer.php index 9dcc608..5582470 100644 --- a/src/Presentation/Api/V1/Exceptions/ApiExceptionRenderer.php +++ b/src/Presentation/Api/V1/Exceptions/ApiExceptionRenderer.php @@ -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; } diff --git a/src/Presentation/Api/V1/Http/Middlewares/EnsureApiV1AcceptsJson.php b/src/Presentation/Api/V1/Http/Middlewares/EnsureApiV1AcceptsJson.php index 7c2fc77..ab49a80 100644 --- a/src/Presentation/Api/V1/Http/Middlewares/EnsureApiV1AcceptsJson.php +++ b/src/Presentation/Api/V1/Http/Middlewares/EnsureApiV1AcceptsJson.php @@ -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')) { diff --git a/src/Presentation/Api/V1/Http/Middlewares/SetApiV1Etag.php b/src/Presentation/Api/V1/Http/Middlewares/SetApiV1Etag.php index 42d102b..93fe699 100644 --- a/src/Presentation/Api/V1/Http/Middlewares/SetApiV1Etag.php +++ b/src/Presentation/Api/V1/Http/Middlewares/SetApiV1Etag.php @@ -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); diff --git a/src/Presentation/Api/V1/Http/Middlewares/TransformApiV1Response.php b/src/Presentation/Api/V1/Http/Middlewares/TransformApiV1Response.php index c87c231..52b61e1 100644 --- a/src/Presentation/Api/V1/Http/Middlewares/TransformApiV1Response.php +++ b/src/Presentation/Api/V1/Http/Middlewares/TransformApiV1Response.php @@ -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); diff --git a/src/Presentation/Api/V1/Http/Middlewares/VerifyHmacSignature.php b/src/Presentation/Api/V1/Http/Middlewares/VerifyHmacSignature.php index 0f50cb5..029f273 100644 --- a/src/Presentation/Api/V1/Http/Middlewares/VerifyHmacSignature.php +++ b/src/Presentation/Api/V1/Http/Middlewares/VerifyHmacSignature.php @@ -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); diff --git a/src/Presentation/Web/Exceptions/WebExceptionRenderer.php b/src/Presentation/Web/Exceptions/WebExceptionRenderer.php index 57f075f..6dc28ac 100644 --- a/src/Presentation/Web/Exceptions/WebExceptionRenderer.php +++ b/src/Presentation/Web/Exceptions/WebExceptionRenderer.php @@ -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; diff --git a/src/Presentation/Web/resources/views/index.blade.php b/src/Presentation/Web/resources/views/index.blade.php index d42a273..6f42414 100644 --- a/src/Presentation/Web/resources/views/index.blade.php +++ b/src/Presentation/Web/resources/views/index.blade.php @@ -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') diff --git a/src/Shared/DDD/Usecase/Builder/UsecaseExecutionBuilder.php b/src/Shared/DDD/Usecase/Builder/UsecaseExecutionBuilder.php index 9985dd6..d177d35 100644 --- a/src/Shared/DDD/Usecase/Builder/UsecaseExecutionBuilder.php +++ b/src/Shared/DDD/Usecase/Builder/UsecaseExecutionBuilder.php @@ -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 */ diff --git a/src/Shared/ValueObjects/Config/AppName.php b/src/Shared/ValueObjects/Config/AppName.php new file mode 100644 index 0000000..202cf2a --- /dev/null +++ b/src/Shared/ValueObjects/Config/AppName.php @@ -0,0 +1,36 @@ +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; + } +} diff --git a/src/Shared/ValueObjects/Config/AppTimezone.php b/src/Shared/ValueObjects/Config/AppTimezone.php new file mode 100644 index 0000000..6f7447d --- /dev/null +++ b/src/Shared/ValueObjects/Config/AppTimezone.php @@ -0,0 +1,47 @@ +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; + } +} diff --git a/src/Shared/ValueObjects/Config/Locale.php b/src/Shared/ValueObjects/Config/Locale.php new file mode 100644 index 0000000..7ce6a1a --- /dev/null +++ b/src/Shared/ValueObjects/Config/Locale.php @@ -0,0 +1,48 @@ +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; + } +} diff --git a/src/Shared/ValueObjects/Config/RequestsPerMinute.php b/src/Shared/ValueObjects/Config/RequestsPerMinute.php new file mode 100644 index 0000000..34702fa --- /dev/null +++ b/src/Shared/ValueObjects/Config/RequestsPerMinute.php @@ -0,0 +1,41 @@ +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; + } +} diff --git a/src/Shared/ValueObjects/Email.php b/src/Shared/ValueObjects/Email.php new file mode 100644 index 0000000..815dfc6 --- /dev/null +++ b/src/Shared/ValueObjects/Email.php @@ -0,0 +1,36 @@ +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; + } +} diff --git a/tests/Architecture/CodingStandardsTest.php b/tests/Architecture/CodingStandardsTest.php index c6c7b02..232f48c 100644 --- a/tests/Architecture/CodingStandardsTest.php +++ b/tests/Architecture/CodingStandardsTest.php @@ -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(); }); }); diff --git a/tests/Integration/Infra/Providers/ConfigServiceProviderTest.php b/tests/Integration/Infra/Providers/ConfigServiceProviderTest.php new file mode 100644 index 0000000..4bee6f9 --- /dev/null +++ b/tests/Integration/Infra/Providers/ConfigServiceProviderTest.php @@ -0,0 +1,69 @@ +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(); + }); +}); diff --git a/tests/Integration/Presentation/Api/ApiExceptionDispatcherTest.php b/tests/Integration/Presentation/Api/ApiExceptionDispatcherTest.php index 3accf53..458f309 100644 --- a/tests/Integration/Presentation/Api/ApiExceptionDispatcherTest.php +++ b/tests/Integration/Presentation/Api/ApiExceptionDispatcherTest.php @@ -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 $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 $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'); }); }); diff --git a/tests/Integration/Presentation/Api/V1/ApiExceptionRendererTest.php b/tests/Integration/Presentation/Api/V1/ApiExceptionRendererTest.php index 14daf84..c198df0 100644 --- a/tests/Integration/Presentation/Api/V1/ApiExceptionRendererTest.php +++ b/tests/Integration/Presentation/Api/V1/ApiExceptionRendererTest.php @@ -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'); diff --git a/tests/Unit/Application/Arcadia/Config/ArcadiaConfigsTest.php b/tests/Unit/Application/Arcadia/Config/ArcadiaConfigsTest.php new file mode 100644 index 0000000..10bac47 --- /dev/null +++ b/tests/Unit/Application/Arcadia/Config/ArcadiaConfigsTest.php @@ -0,0 +1,93 @@ +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', + ]); +}); diff --git a/tests/Unit/Application/Security/Config/SecurityConfigsTest.php b/tests/Unit/Application/Security/Config/SecurityConfigsTest.php new file mode 100644 index 0000000..492ef06 --- /dev/null +++ b/tests/Unit/Application/Security/Config/SecurityConfigsTest.php @@ -0,0 +1,58 @@ +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'); + }); +}); diff --git a/tests/Unit/Infra/Listeners/SendClientSecurityAlertMailTest.php b/tests/Unit/Infra/Listeners/SendClientSecurityAlertMailTest.php index 3711074..fb6e45e 100644 --- a/tests/Unit/Infra/Listeners/SendClientSecurityAlertMailTest.php +++ b/tests/Unit/Infra/Listeners/SendClientSecurityAlertMailTest.php @@ -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); diff --git a/tests/Unit/Shared/ValueObjects/Config/AppNameTest.php b/tests/Unit/Shared/ValueObjects/Config/AppNameTest.php new file mode 100644 index 0000000..af4ff6a --- /dev/null +++ b/tests/Unit/Shared/ValueObjects/Config/AppNameTest.php @@ -0,0 +1,42 @@ +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(); + }); +}); diff --git a/tests/Unit/Shared/ValueObjects/Config/AppTimezoneTest.php b/tests/Unit/Shared/ValueObjects/Config/AppTimezoneTest.php new file mode 100644 index 0000000..d04cad2 --- /dev/null +++ b/tests/Unit/Shared/ValueObjects/Config/AppTimezoneTest.php @@ -0,0 +1,54 @@ +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(); + }); +}); diff --git a/tests/Unit/Shared/ValueObjects/Config/LocaleTest.php b/tests/Unit/Shared/ValueObjects/Config/LocaleTest.php new file mode 100644 index 0000000..126954e --- /dev/null +++ b/tests/Unit/Shared/ValueObjects/Config/LocaleTest.php @@ -0,0 +1,51 @@ +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(); + }); +}); diff --git a/tests/Unit/Shared/ValueObjects/Config/RequestsPerMinuteTest.php b/tests/Unit/Shared/ValueObjects/Config/RequestsPerMinuteTest.php new file mode 100644 index 0000000..7310222 --- /dev/null +++ b/tests/Unit/Shared/ValueObjects/Config/RequestsPerMinuteTest.php @@ -0,0 +1,39 @@ +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(); + }); +}); diff --git a/tests/Unit/Shared/ValueObjects/EmailTest.php b/tests/Unit/Shared/ValueObjects/EmailTest.php new file mode 100644 index 0000000..cb1607f --- /dev/null +++ b/tests/Unit/Shared/ValueObjects/EmailTest.php @@ -0,0 +1,49 @@ +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(); + }); +});