From 62ccc84c6be21d21b6e7684fcd77583b1f29dc6f Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Wed, 23 Jul 2025 14:56:37 -0400 Subject: [PATCH 01/73] Introduce `ErrorHandler` and `StatelessApplication` implementation with tests. --- src/errorhandler/ErrorHandler.php | 102 ++++++++++++ src/http/StatelessApplication.php | 266 ++++++++++++++++++++++++++++++ tests/phpstan-config.php | 13 ++ 3 files changed, 381 insertions(+) create mode 100644 src/errorhandler/ErrorHandler.php create mode 100644 src/http/StatelessApplication.php diff --git a/src/errorhandler/ErrorHandler.php b/src/errorhandler/ErrorHandler.php new file mode 100644 index 00000000..a5107061 --- /dev/null +++ b/src/errorhandler/ErrorHandler.php @@ -0,0 +1,102 @@ +exception = $exception; + + $this->unregister(); + + if (PHP_SAPI !== 'cli') { + http_response_code(500); + } + + try { + $this->logException($exception); + + if ($this->discardExistingOutput) { + $this->clearOutput(); + } + + $response = $this->renderException($exception); + } catch (Throwable $e) { + return $this->handleFallbackExceptionMessage($e, $exception); + } + + $this->exception = null; + + return $response; + } + + + protected function handleFallbackExceptionMessage($exception, $previousException): Response + { + $response = new Response(); + + $msg = "An Error occurred while handling another error:\n"; + + $msg .= (string) $exception; + $msg .= "\nPrevious exception:\n"; + $msg .= (string) $previousException; + + $response->data = 'An internal server error occurred.'; + + if (YII_DEBUG) { + $response->data = '
' . htmlspecialchars($msg, ENT_QUOTES, Yii::$app->charset) . '
'; + } + + $response->data .= "\n\$_SERVER = " . VarDumper::export($_SERVER); + + error_log($response->data); + + return $response; + } + + protected function renderException($exception): Response + { + $response = new Response(); + + $response->setStatusCodeByException($exception); + + $useErrorView = $response->format === Response::FORMAT_HTML && (!YII_DEBUG || $exception instanceof UserException); + + if ($useErrorView && $this->errorAction !== null) { + $result = Yii::$app->runAction($this->errorAction); + + if ($result instanceof Response) { + $response = $result; + } else { + $response->data = $result; + } + } elseif ($response->format === Response::FORMAT_HTML) { + if ($this->shouldRenderSimpleHtml()) { + $response->data = '
' . $this->htmlEncode(static::convertExceptionToString($exception)) . '
'; + } else { + if (YII_DEBUG) { + ini_set('display_errors', 'true'); + } + + $file = $useErrorView ? $this->errorView : $this->exceptionView; + + $response->data = $this->renderFile($file, ['exception' => $exception]); + } + } elseif ($response->format === Response::FORMAT_RAW) { + $response->data = static::convertExceptionToString($exception); + } else { + $response->data = $this->convertExceptionToArray($exception); + } + + return $response; + } +} diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php new file mode 100644 index 00000000..74555019 --- /dev/null +++ b/src/http/StatelessApplication.php @@ -0,0 +1,266 @@ + + */ + private array $config = []; + + /** + * @phpstan-var callable(Event $event): void + */ + private $eventHandler; + + private int|null $memoryLimit = null; + + /** + * @phpstan-var array + */ + private array $registeredEvents = []; + + /** + * @phpstan-param array $config + * + * @phpstan-ignore constructor.missingParentCall + */ + public function __construct(array $config = []) + { + $this->config = $config; + + // this is necessary to get \yii\web\Session to work properly. + ini_set('use_cookies', 'false'); + ini_set('use_only_cookies', 'true'); + + $this->memoryLimit = $this->getMemoryLimit(); + $this->initEventTracking(); + } + + public function clean(): bool + { + gc_collect_cycles(); + + $limit = (int) $this->memoryLimit; + $bound = $limit * .90; + + $usage = memory_get_usage(true); + + return $usage >= $bound ? true : false; + } + + /** + * @phpstan-return array + */ + public function coreComponents(): array + { + return array_merge( + parent::coreComponents(), + [ + 'errorHandler' => [ + 'class' => ErrorHandler::class, + ], + 'request' => [ + 'class' => Request::class, + ], + 'response' => [ + 'class' => Response::class, + ], + 'session' => [ + 'class' => Session::class, + ], + 'user' => [ + 'class' => User::class, + ], + ], + ); + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + try { + $this->reset($request); + + $this->state = self::STATE_BEFORE_REQUEST; + + $this->trigger(self::EVENT_BEFORE_REQUEST); + + $this->state = self::STATE_HANDLING_REQUEST; + + $response = $this->handleRequest($this->getRequest()); + + $this->state = self::STATE_AFTER_REQUEST; + + $this->trigger(self::EVENT_AFTER_REQUEST); + + $this->state = self::STATE_END; + + return $this->terminate($response); + } catch (Throwable $e) { + return $this->terminate($this->handleError($e)); + } + } + + public function init(): void + { + $this->state = self::STATE_INIT; + } + + protected function bootstrap(): void + { + $request = $this->getRequest(); + + Yii::setAlias('@webroot', dirname($request->getScriptFile())); + Yii::setAlias('@web', $request->getBaseUrl()); + + parent::bootstrap(); + } + + protected function reset(ServerRequestInterface $request): void + { + // override YII_BEGIN_TIME if possible for yii2-debug and other modules that depend on it + if (\function_exists('uopz_redefine')) { + \uopz_redefine('YII_BEGIN_TIME', microtime(true)); + } + + $this->startEventTracking(); + + $config = $this->config; + + if ($this->has('errorHandler')) { + $this->errorHandler->unregister(); + } + + // @phpstan-ignore-next-line + parent::__construct($config); + + $this->requestedRoute = ''; + $this->requestedAction = null; + $this->requestedParams = []; + + if ($this->getRequest() instanceof Request) { + $this->getRequest()->setPsr7Request($request); + } + + $this->session->close(); + $sessionId = $request->getCookieParams()[$this->session->getName()] ?? null; + + if ($sessionId !== null && is_string($sessionId)) { + $this->session->setId($sessionId); + } + + $this->ensureBehaviors(); + + $this->session->open(); + + $this->bootstrap(); + + $this->session->close(); + } + + protected function terminate(WebResponse $response): ResponseInterface + { + $this->cleanupEvents(); + + UploadedFile::reset(); + + Yii::getLogger()->flush(true); + + if ($this->getRequest() instanceof Request) { + $this->getRequest()->reset(); + } + + if ($response instanceof Response === false) { + throw new InvalidConfigException('Response must be an instance of: ' . Response::class); + } + + return $response->getPsr7Response(); + } + + private function cleanupEvents(): void + { + Event::off('*', '*', $this->eventHandler); + + foreach (array_reverse($this->registeredEvents) as $event) { + if ($event->sender !== null && method_exists($event->sender, 'off')) { + $event->sender->off($event->name); + } + } + + $this->registeredEvents = []; + + Event::offAll(); + } + + private function getMemoryLimit(): int + { + if ($this->memoryLimit === null || $this->memoryLimit <= 0) { + $limit = ini_get('memory_limit'); + + sscanf($limit, '%u%c', $number, $suffix); + + if (isset($suffix)) { + $multipliers = [' ' => 1, 'K' => 1024, 'M' => 1048576, 'G' => 1073741824]; + + $suffix = strtoupper((string) $suffix); + + $number = (int) $number * ($multipliers[$suffix] ?? 1); + } + + $this->memoryLimit = (int) $number; + } + + return $this->memoryLimit; + } + + private function handleError(Throwable $exception): Response + { + $errorHandler = $this->getErrorHandler(); + + if ($errorHandler instanceof ErrorHandler === false) { + throw new InvalidConfigException('Error handler must be an instance of: ' . ErrorHandler::class); + } + + $response = $errorHandler->handleException($exception); + + $this->trigger(self::EVENT_AFTER_REQUEST); + + $this->state = self::STATE_END; + + return $response; + } + + private function initEventTracking(): void + { + $this->eventHandler = function (Event $event): void { + $this->registeredEvents[] = $event; + }; + } + + private function startEventTracking(): void + { + Event::on('*', '*', $this->eventHandler); + } +} diff --git a/tests/phpstan-config.php b/tests/phpstan-config.php index 23ce8913..129b22a7 100644 --- a/tests/phpstan-config.php +++ b/tests/phpstan-config.php @@ -4,8 +4,21 @@ use HttpSoft\Message\{ResponseFactory, StreamFactory}; use Psr\Http\Message\{ResponseFactoryInterface, StreamFactoryInterface}; +use yii2\extensions\psrbridge\errorhandler\ErrorHandler; +use yii2\extensions\psrbridge\http\{Request, Response}; return [ + 'components' => [ + 'errorHandler' => [ + 'class' => ErrorHandler::class, + ], + 'request' => [ + 'class' => Request::class, + ], + 'response' => [ + 'class' => Response::class, + ], + ], 'container' => [ 'definitions' => [ ResponseFactoryInterface::class => ResponseFactory::class, From 84baf19a2d12a0c4443517b3808f2d9ea2d35368 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Wed, 23 Jul 2025 18:57:03 +0000 Subject: [PATCH 02/73] Apply fixes from StyleCI --- src/errorhandler/ErrorHandler.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/errorhandler/ErrorHandler.php b/src/errorhandler/ErrorHandler.php index a5107061..05c7e1b4 100644 --- a/src/errorhandler/ErrorHandler.php +++ b/src/errorhandler/ErrorHandler.php @@ -39,7 +39,6 @@ public function handleException($exception): Response return $response; } - protected function handleFallbackExceptionMessage($exception, $previousException): Response { $response = new Response(); @@ -81,7 +80,7 @@ protected function renderException($exception): Response } } elseif ($response->format === Response::FORMAT_HTML) { if ($this->shouldRenderSimpleHtml()) { - $response->data = '
' . $this->htmlEncode(static::convertExceptionToString($exception)) . '
'; + $response->data = '
' . $this->htmlEncode(self::convertExceptionToString($exception)) . '
'; } else { if (YII_DEBUG) { ini_set('display_errors', 'true'); @@ -92,7 +91,7 @@ protected function renderException($exception): Response $response->data = $this->renderFile($file, ['exception' => $exception]); } } elseif ($response->format === Response::FORMAT_RAW) { - $response->data = static::convertExceptionToString($exception); + $response->data = self::convertExceptionToString($exception); } else { $response->data = $this->convertExceptionToArray($exception); } From 1ad1ab96dcc33f4bfb4b75be2537071f1b756294 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Wed, 23 Jul 2025 14:58:22 -0400 Subject: [PATCH 03/73] fix(composer): add missing `psr/http-server-handler` dependency. --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index a9f5cab1..98068eb0 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "php": ">=8.1", "psr/http-message": "^2.0", "psr/http-factory": "^1.0", + "psr/http-server-handler": "^1.0", "yiisoft/yii2": "^2.0.53|^22" }, "require-dev": { From 38ee5eca53c7c142ebc27b011b91b2f01301bd9e Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 25 Jul 2025 12:29:17 -0400 Subject: [PATCH 04/73] Add `composer-require-checker.json` and update version in `StatelessApplication` class to `0.1.0`. --- composer-require-checker.json | 6 ++++++ src/http/StatelessApplication.php | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 composer-require-checker.json diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 00000000..50a54a46 --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,6 @@ +{ + "symbol-whitelist": [ + "uopz_redefine", + "YII_DEBUG" + ] +} diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 74555019..dac19ca5 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -23,7 +23,7 @@ final class StatelessApplication extends \yii\web\Application implements RequestHandlerInterface { - public string $version = 'wprker-0.1.0'; + public string $version = '0.1.0'; /** * @phpstan-var array @@ -219,6 +219,12 @@ private function getMemoryLimit(): int if ($this->memoryLimit === null || $this->memoryLimit <= 0) { $limit = ini_get('memory_limit'); + if ($limit === '-1') { + $this->memoryLimit = PHP_INT_MAX; + + return $this->memoryLimit; + } + sscanf($limit, '%u%c', $number, $suffix); if (isset($suffix)) { From e031014bdc20d99c19dbb34769c25a7d30f0dca7 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 25 Jul 2025 12:31:36 -0400 Subject: [PATCH 05/73] fix(ErrorHandler): move server variable logging inside debug condition. --- src/errorhandler/ErrorHandler.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/errorhandler/ErrorHandler.php b/src/errorhandler/ErrorHandler.php index 05c7e1b4..fb42882a 100644 --- a/src/errorhandler/ErrorHandler.php +++ b/src/errorhandler/ErrorHandler.php @@ -53,10 +53,9 @@ protected function handleFallbackExceptionMessage($exception, $previousException if (YII_DEBUG) { $response->data = '
' . htmlspecialchars($msg, ENT_QUOTES, Yii::$app->charset) . '
'; + $response->data .= "\n\$_SERVER = " . VarDumper::export($_SERVER); } - $response->data .= "\n\$_SERVER = " . VarDumper::export($_SERVER); - error_log($response->data); return $response; From bab0cb52101a15f4104ff6b954df2f80b9ebdf60 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 25 Jul 2025 12:34:21 -0400 Subject: [PATCH 06/73] fix(StatelessApplication): simplify return statement in clean method. --- src/http/StatelessApplication.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index dac19ca5..39c5b279 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -68,7 +68,7 @@ public function clean(): bool $usage = memory_get_usage(true); - return $usage >= $bound ? true : false; + return $usage >= $bound; } /** From 78322c8f20436ee6d5b398f9b837329841df19cd Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 25 Jul 2025 12:38:01 -0400 Subject: [PATCH 07/73] fix(composer.json): add ext-uopz to require-dev dependencies fix(phpstan.neon): ensure yii2 config path is set correctly. --- composer.json | 1 + phpstan.neon | 3 +++ 2 files changed, 4 insertions(+) diff --git a/composer.json b/composer.json index 98068eb0..fefc99a6 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "yiisoft/yii2": "^2.0.53|^22" }, "require-dev": { + "ext-uopz": "*", "infection/infection": "^0.27|^0.30", "httpsoft/http-message": "^1.1", "maglnet/composer-require-checker": "^4.1", diff --git a/phpstan.neon b/phpstan.neon index 735031cc..7adfced3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -13,6 +13,9 @@ parameters: tmpDir: %currentWorkingDirectory%/runtime + yii2: + config_path: %currentWorkingDirectory%/tests/phpstan-config.php + # Enable strict advanced checks checkImplicitMixed: true checkBenevolentUnionTypes: true From c1571696fbd181e4c684406e992c478599caa8c7 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 25 Jul 2025 12:42:30 -0400 Subject: [PATCH 08/73] fix(workflows): add uopz extension to various workflow configurations. --- .github/workflows/build.yml | 2 ++ .github/workflows/dependency-check.yml | 2 ++ .github/workflows/ecs.yml | 2 ++ .github/workflows/mutation.yml | 1 + .github/workflows/static.yml | 2 ++ 5 files changed, 9 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a1295cb3..6b078ab9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,3 +22,5 @@ jobs: uses: php-forge/actions/.github/workflows/phpunit.yml@main secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + extensions: uopz diff --git a/.github/workflows/dependency-check.yml b/.github/workflows/dependency-check.yml index a5390cfb..76d25819 100644 --- a/.github/workflows/dependency-check.yml +++ b/.github/workflows/dependency-check.yml @@ -20,3 +20,5 @@ name: Composer require checker jobs: composer-require-checker: uses: php-forge/actions/.github/workflows/composer-require-checker.yml@main + with: + extensions: uopz diff --git a/.github/workflows/ecs.yml b/.github/workflows/ecs.yml index 5350294c..f4770980 100644 --- a/.github/workflows/ecs.yml +++ b/.github/workflows/ecs.yml @@ -20,3 +20,5 @@ name: ecs jobs: easy-coding-standard: uses: php-forge/actions/.github/workflows/ecs.yml@main + with: + extensions: uopz diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 9c6f1ec3..d63406fd 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -21,6 +21,7 @@ jobs: mutation: uses: php-forge/actions/.github/workflows/infection.yml@main with: + extensions: uopz phpstan: true secrets: STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 0ad0ebad..d3460e77 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -20,3 +20,5 @@ name: static analysis jobs: phpstan: uses: php-forge/actions/.github/workflows/phpstan.yml@main + with: + extensions: uopz From 70362227a5a1b2b2bb2627ee440b3dff094777d2 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 25 Jul 2025 12:49:01 -0400 Subject: [PATCH 09/73] fix(build): specify uopz extension version in workflow configuration. --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b078ab9..ba7b6550 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,4 +23,4 @@ jobs: secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: - extensions: uopz + extensions: uopz-7.1.1 From f75a4d72c307840e9010338dc1cd59af08be258b Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 25 Jul 2025 12:59:55 -0400 Subject: [PATCH 10/73] fix(workflows): remove uopz extension from various workflow configurations. --- .github/workflows/build.yml | 2 -- .github/workflows/dependency-check.yml | 2 -- .github/workflows/ecs.yml | 2 -- .github/workflows/mutation.yml | 1 - .github/workflows/static.yml | 2 -- composer.json | 4 +++- 6 files changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ba7b6550..a1295cb3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,5 +22,3 @@ jobs: uses: php-forge/actions/.github/workflows/phpunit.yml@main secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - with: - extensions: uopz-7.1.1 diff --git a/.github/workflows/dependency-check.yml b/.github/workflows/dependency-check.yml index 76d25819..a5390cfb 100644 --- a/.github/workflows/dependency-check.yml +++ b/.github/workflows/dependency-check.yml @@ -20,5 +20,3 @@ name: Composer require checker jobs: composer-require-checker: uses: php-forge/actions/.github/workflows/composer-require-checker.yml@main - with: - extensions: uopz diff --git a/.github/workflows/ecs.yml b/.github/workflows/ecs.yml index f4770980..5350294c 100644 --- a/.github/workflows/ecs.yml +++ b/.github/workflows/ecs.yml @@ -20,5 +20,3 @@ name: ecs jobs: easy-coding-standard: uses: php-forge/actions/.github/workflows/ecs.yml@main - with: - extensions: uopz diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index d63406fd..9c6f1ec3 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -21,7 +21,6 @@ jobs: mutation: uses: php-forge/actions/.github/workflows/infection.yml@main with: - extensions: uopz phpstan: true secrets: STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index d3460e77..0ad0ebad 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -20,5 +20,3 @@ name: static analysis jobs: phpstan: uses: php-forge/actions/.github/workflows/phpstan.yml@main - with: - extensions: uopz diff --git a/composer.json b/composer.json index fefc99a6..99766f7e 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,6 @@ "yiisoft/yii2": "^2.0.53|^22" }, "require-dev": { - "ext-uopz": "*", "infection/infection": "^0.27|^0.30", "httpsoft/http-message": "^1.1", "maglnet/composer-require-checker": "^4.1", @@ -30,6 +29,9 @@ "xepozz/internal-mocker": "^1.4", "yii2-extensions/phpstan": "^0.3" }, + "suggest": { + "ext-uopz": "*" + }, "autoload": { "psr-4": { "yii2\\extensions\\psrbridge\\": "src" From 3d6a280b3b5633cf4ae3a50bbf9c56f63f14734a Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 25 Jul 2025 19:54:13 -0400 Subject: [PATCH 11/73] feat(ErrorHandler): implement custom error handling for PSR-7 integration. --- src/{errorhandler => http}/ErrorHandler.php | 15 +- src/http/StatelessApplication.php | 261 +++++++++++++++++--- tests/phpstan-config.php | 3 +- 3 files changed, 242 insertions(+), 37 deletions(-) rename src/{errorhandler => http}/ErrorHandler.php (91%) diff --git a/src/errorhandler/ErrorHandler.php b/src/http/ErrorHandler.php similarity index 91% rename from src/errorhandler/ErrorHandler.php rename to src/http/ErrorHandler.php index fb42882a..32ee1d95 100644 --- a/src/errorhandler/ErrorHandler.php +++ b/src/http/ErrorHandler.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace yii2\extensions\psrbridge\errorhandler; +namespace yii2\extensions\psrbridge\http; use Throwable; use Yii; +use yii\base\InvalidRouteException; use yii\base\UserException; +use yii\console\Exception; use yii\helpers\VarDumper; -use yii2\extensions\psrbridge\http\Response; final class ErrorHandler extends \yii\web\ErrorHandler { @@ -45,9 +46,9 @@ protected function handleFallbackExceptionMessage($exception, $previousException $msg = "An Error occurred while handling another error:\n"; - $msg .= (string) $exception; + $msg .= $exception; $msg .= "\nPrevious exception:\n"; - $msg .= (string) $previousException; + $msg .= $previousException; $response->data = 'An internal server error occurred.'; @@ -56,11 +57,13 @@ protected function handleFallbackExceptionMessage($exception, $previousException $response->data .= "\n\$_SERVER = " . VarDumper::export($_SERVER); } - error_log($response->data); - return $response; } + /** + * @throws Exception + * @throws InvalidRouteException + */ protected function renderException($exception): Response { $response = new Response(); diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 39c5b279..1ae894f1 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -10,39 +10,85 @@ use Yii; use yii\base\Event; use yii\base\InvalidConfigException; -use yii\web\Response as WebResponse; -use yii\web\{Session, UploadedFile, User}; -use yii2\extensions\psrbridge\errorhandler\ErrorHandler; +use yii\di\NotInstantiableException; +use yii\web\{Application, Session, UploadedFile, User}; use function array_merge; +use function array_reverse; +use function dirname; +use function function_exists; use function gc_collect_cycles; use function ini_get; +use function ini_set; +use function is_string; use function memory_get_usage; +use function method_exists; +use function microtime; use function sscanf; use function strtoupper; - -final class StatelessApplication extends \yii\web\Application implements RequestHandlerInterface +use function uopz_redefine; + +/** + * Stateless Yii2 Application with PSR-7 RequestHandler integration for worker and SAPI environments. + * + * Provides a Yii2 application implementation designed for stateless operation and seamless interoperability with PSR-7 + * compatible HTTP stacks and modern PHP runtimes. + * + * This class implements {@see RequestHandlerInterface} to support direct handling of PSR-7 ServerRequestInterface + * instances, enabling integration with worker-based environments and SAPI. + * + * It manages the full application lifecycle, including request/response handling, event tracking, session management, + * and error handling, while maintaining strict type safety and immutability throughout the process. + * + * Key features. + * - Event tracking and cleanup for robust lifecycle management. + * - Exception-safe error handling and response conversion. + * - Immutable, type-safe application state management. + * - PSR-7 RequestHandlerInterface implementation for direct PSR-7 integration. + * - Session and user management from PSR-7 cookies. + * - Stateless, repeatable request handling for worker and SAPI runtimes. + * + * @see RequestHandlerInterface for PSR-7 request handling contract. + * + * @copyright Copyright (C) 2025 Terabytesoftw. + * @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License. + */ +final class StatelessApplication extends Application implements RequestHandlerInterface { + /** + * Version of the StatelessApplication. + */ public string $version = '0.1.0'; /** + * Configuration for the StatelessApplication. + * * @phpstan-var array */ private array $config = []; /** + * Event handler for tracking events. + * * @phpstan-var callable(Event $event): void */ private $eventHandler; + /** + * Memory limit for the StatelessApplication. + */ private int|null $memoryLimit = null; /** + * Registered events during the application lifecycle. + * * @phpstan-var array */ private array $registeredEvents = []; /** + * Creates a new instance of the {@see StatelessApplication} class. + * * @phpstan-param array $config * * @phpstan-ignore constructor.missingParentCall @@ -51,14 +97,33 @@ public function __construct(array $config = []) { $this->config = $config; - // this is necessary to get \yii\web\Session to work properly. + // this is necessary to get \yii\web\Session to work ini_set('use_cookies', 'false'); ini_set('use_only_cookies', 'true'); $this->memoryLimit = $this->getMemoryLimit(); + $this->initEventTracking(); } + /** + * Performs memory cleanup and checks if memory usage exceeds the configured threshold. + * + * Invokes garbage collection cycles and compares the current memory usage against 90% of the configured memory + * limit. + * + * This method is used to determine if the application should be recycled or restarted based on memory consumption, + * supporting stateless operation in worker and SAPI environments. + * + * @return bool `true` if memory usage is greater than or equal to 90% of the memory limit, `false` otherwise. + * + * Usage example: + * ```php + * if ($app->clean()) { + * // trigger worker recycle or restart + * } + * ``` + */ public function clean(): bool { gc_collect_cycles(); @@ -72,7 +137,22 @@ public function clean(): bool } /** + * Returns the core components configuration for the {@see StatelessApplication}. + * + * Provides the array of core Yii2 components required for stateless operation, including error handler, request, + * response, session, and user components. + * + * This configuration ensures that the application is initialized with PSR-7 bridge support and compatible with + * worker and SAPI environments. + * + * @return array Array of core component configurations for the application. + * * @phpstan-return array + * + * Usage example: + * ```php + * $components = $app->coreComponents(); + * ``` */ public function coreComponents(): array { @@ -98,6 +178,28 @@ public function coreComponents(): array ); } + /** + * Handles a PSR-7 ServerRequestInterface and returns a PSR-7 ResponseInterface. + * + * Processes the full Yii2 application lifecycle for a stateless request, including event triggering, request + * handling, and error management. + * + * This method resets the application state, triggers lifecycle events, executes the request, and converts the + * result to a PSR-7 ResponseInterface. + * + * If an exception occurs during processing, it is handled and converted to a PSR-7 response. + * + * @param ServerRequestInterface $request PSR-7 ServerRequestInterface instance to handle. + * + * @throws InvalidConfigException if the configuration is invalid or incomplete. + * + * @return ResponseInterface PSR-7 ResponseInterface instance representing the result of the handled request. + * + * Usage example: + * ```php + * $psrResponse = $app->handle($psrRequest); + * ``` + */ public function handle(ServerRequestInterface $request): ResponseInterface { try { @@ -109,7 +211,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface $this->state = self::STATE_HANDLING_REQUEST; - $response = $this->handleRequest($this->getRequest()); + /** @phpstan-var Response $response */ + $response = $this->handleRequest($this->request); $this->state = self::STATE_AFTER_REQUEST; @@ -123,11 +226,36 @@ public function handle(ServerRequestInterface $request): ResponseInterface } } + /** + * Initializes the StatelessApplication state to 'STATE_INIT'. + * + * Sets the internal application state to {@see self::STATE_INIT}, preparing the application for initialization and + * lifecycle event tracking. + * + * This method is called during the application bootstrap process to ensure the application state is initialized + * before handling requests or triggering events. + * + * Usage example: + * ```php + * $app->init(); + * ``` + */ public function init(): void { $this->state = self::STATE_INIT; } + /** + * Bootstraps the StatelessApplication by setting core path aliases and invoking parent bootstrap logic. + * + * Sets the '@webroot' and '@web' path aliases based on the current request, ensuring correct path resolution for + * asset management and routing in stateless and worker environments. + * + * This method prepares the application for request handling by configuring essential Yii2 path aliases before + * delegating to the parent bootstrap implementation. + * + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ protected function bootstrap(): void { $request = $this->getRequest(); @@ -138,11 +266,26 @@ protected function bootstrap(): void parent::bootstrap(); } + /** + * Resets the StatelessApplication state and prepares the Yii2 environment for handling a PSR-7 request. + * + * Performs a full reinitialization of the application state, including event tracking, error handler cleanup, + * session management, and PSR-7 request injection. + * + * This method ensures that the application is ready to process a new stateless request in worker or SAPI + * environments, maintaining strict type safety and compatibility with Yii2 core components. + * + * This method is called internally before each request is handled to guarantee stateless, repeatable operation. + * + * @param ServerRequestInterface $request PSR-7 ServerRequestInterface instance to inject into the application. + * + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ protected function reset(ServerRequestInterface $request): void { - // override YII_BEGIN_TIME if possible for yii2-debug and other modules that depend on it - if (\function_exists('uopz_redefine')) { - \uopz_redefine('YII_BEGIN_TIME', microtime(true)); + // override 'YII_BEGIN_TIME' if possible for yii2-debug and other modules that depend on it + if (function_exists('uopz_redefine')) { + uopz_redefine('YII_BEGIN_TIME', microtime(true)); } $this->startEventTracking(); @@ -160,14 +303,12 @@ protected function reset(ServerRequestInterface $request): void $this->requestedAction = null; $this->requestedParams = []; - if ($this->getRequest() instanceof Request) { - $this->getRequest()->setPsr7Request($request); - } + $this->request->setPsr7Request($request); $this->session->close(); $sessionId = $request->getCookieParams()[$this->session->getName()] ?? null; - if ($sessionId !== null && is_string($sessionId)) { + if (is_string($sessionId)) { $this->session->setId($sessionId); } @@ -180,7 +321,22 @@ protected function reset(ServerRequestInterface $request): void $this->session->close(); } - protected function terminate(WebResponse $response): ResponseInterface + /** + * Finalizes the application lifecycle and converts the Yii2 Response to a PSR-7 ResponseInterface. + * + * Cleans up registered events, resets uploaded files, flushes the logger, and resets the request state. + * + * This method ensures that all application resources are released and the response is converted to a PSR-7 + * ResponseInterface for interoperability with PSR-7 compatible HTTP stacks. + * + * @param Response $response Response instance to convert and finalize. + * + * @throws InvalidConfigException if the configuration is invalid or incomplete. + * @throws NotInstantiableException if a class or service can't be instantiated. + * + * @return ResponseInterface PSR-7 ResponseInterface instance representing the finalized response. + */ + protected function terminate(Response $response): ResponseInterface { $this->cleanupEvents(); @@ -188,17 +344,25 @@ protected function terminate(WebResponse $response): ResponseInterface Yii::getLogger()->flush(true); - if ($this->getRequest() instanceof Request) { - $this->getRequest()->reset(); - } - - if ($response instanceof Response === false) { - throw new InvalidConfigException('Response must be an instance of: ' . Response::class); - } + $this->request->reset(); return $response->getPsr7Response(); } + /** + * Cleans up all registered events and resets event tracking for the application lifecycle. + * + * Removes all event handlers registered during the application lifecycle, including global and sender-specific + * events, ensuring that no lingering event listeners remain after request processing. + * + * This method iterates over all registered events in reverse order, detaching each from its sender if possible, and + * clears the internal event registry. + * + * This cleanup is essential for stateless operation in worker and SAPI environments, preventing memory leaks and + * ensuring repeatable request handling. + * + * After all events are removed, global event tracking is reset to maintain a clean application state. + */ private function cleanupEvents(): void { Event::off('*', '*', $this->eventHandler); @@ -214,6 +378,19 @@ private function cleanupEvents(): void Event::offAll(); } + /** + * Retrieves and parses the configured PHP memory limit for the application. + * + * Determines the memory limit by reading the 'memory_limit' value from the PHP configuration, parsing the value and + * converting it to an integer representing the number of bytes. + * + * Supports suffixes for kilobytes (K), megabytes (M), and gigabytes (G), and returns 'PHP_INT_MAX' if unlimited. + * + * This method is used to set the internal memory limit for the application, enabling memory usage checks and + * recycling logic in worker and SAPI environments. + * + * @return int Memory limit in bytes as configured in PHP, or 'PHP_INT_MAX' if unlimited. + */ private function getMemoryLimit(): int { if ($this->memoryLimit === null || $this->memoryLimit <= 0) { @@ -241,15 +418,22 @@ private function getMemoryLimit(): int return $this->memoryLimit; } + /** + * Handles application errors and returns a Yii2 Response instance. + * + * Invokes the configured error handler to process the exception and generate a response, then triggers the + * {@see self::EVENT_AFTER_REQUEST} event and sets the application state to {@see self::STATE_END}. + * + * This method ensures that all errors are handled consistently and the application lifecycle is finalized after + * an exception occurs. + * + * @param Throwable $exception Exception instance to handle. + * + * @return Response Response instance generated by the error handler. + */ private function handleError(Throwable $exception): Response { - $errorHandler = $this->getErrorHandler(); - - if ($errorHandler instanceof ErrorHandler === false) { - throw new InvalidConfigException('Error handler must be an instance of: ' . ErrorHandler::class); - } - - $response = $errorHandler->handleException($exception); + $response = $this->errorHandler->handleException($exception); $this->trigger(self::EVENT_AFTER_REQUEST); @@ -258,6 +442,16 @@ private function handleError(Throwable $exception): Response return $response; } + /** + * Initializes the event tracking handler for the application lifecycle. + * + * Sets up the internal event handler used to register events during the application lifecycle. + * + * The handler appends each triggered {@see Event} instance to the internal registry for later cleanup. + * + * This method ensures that all events are tracked and can be detached after request processing, supporting + * stateless operation and preventing memory leaks in worker and SAPI environments. + */ private function initEventTracking(): void { $this->eventHandler = function (Event $event): void { @@ -265,6 +459,15 @@ private function initEventTracking(): void }; } + /** + * Registers the global event handler for application lifecycle event tracking. + * + * Attaches the internal event handler to all events and senders using Yii2 global event registration, enabling the + * application to track every triggered event during the request lifecycle. + * + * This method ensures that all events are captured and appended to the internal registry for later cleanup, + * supporting stateless operation and preventing memory leaks in worker and SAPI environments. + */ private function startEventTracking(): void { Event::on('*', '*', $this->eventHandler); diff --git a/tests/phpstan-config.php b/tests/phpstan-config.php index 129b22a7..3850d1fc 100644 --- a/tests/phpstan-config.php +++ b/tests/phpstan-config.php @@ -4,8 +4,7 @@ use HttpSoft\Message\{ResponseFactory, StreamFactory}; use Psr\Http\Message\{ResponseFactoryInterface, StreamFactoryInterface}; -use yii2\extensions\psrbridge\errorhandler\ErrorHandler; -use yii2\extensions\psrbridge\http\{Request, Response}; +use yii2\extensions\psrbridge\http\{ErrorHandler, Request, Response}; return [ 'components' => [ From 6070ef8ff78009bb09663ea4fc3f4a5307348b1c Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 25 Jul 2025 21:00:25 -0400 Subject: [PATCH 12/73] refactor(ErrorHandler): Enhance documentation and improve exception handling logic and remove unnecessary `ini_set` calls for cookie handling in `StatelessApplication` class. --- src/http/ErrorHandler.php | 103 +++++++++++++++++++++++++++--- src/http/StatelessApplication.php | 5 -- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/http/ErrorHandler.php b/src/http/ErrorHandler.php index 32ee1d95..cf5fafdd 100644 --- a/src/http/ErrorHandler.php +++ b/src/http/ErrorHandler.php @@ -6,13 +6,56 @@ use Throwable; use Yii; -use yii\base\InvalidRouteException; -use yii\base\UserException; +use yii\base\{InvalidRouteException, UserException}; use yii\console\Exception; use yii\helpers\VarDumper; - +use yii\web\HttpException; + +use function array_diff_key; +use function array_flip; +use function htmlspecialchars; +use function http_response_code; +use function ini_set; + +/** + * Error handler extension with PSR-7 bridge support for Yii2 applications. + * + * Provides a drop-in replacement for {@see \yii\web\ErrorHandler} that integrates PSR-7 ResponseInterface handling, + * enabling seamless interoperability with PSR-7 compatible HTTP stacks and modern PHP runtimes. + * + * This class overrides exception handling to produce PSR-7 ResponseInterface objects, supporting custom error views, + * fallback rendering, and Yii2 error action integration. + * + * All exception handling is performed in a type-safe, immutable manner, ensuring compatibility with legacy Yii2 + * workflows and modern middleware stacks. + * + * Key features. + * - Custom error view and error action support for HTML responses. + * - Exception-safe conversion to PSR-7 ResponseInterface objects. + * - Fallback rendering for nested exceptions and debug output. + * - Integration with Yii2 error action and view rendering. + * - Type-safe, immutable error handling for modern runtimes. + * + * @copyright Copyright (C) 2025 Terabytesoftw. + * @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License. + */ final class ErrorHandler extends \yii\web\ErrorHandler { + /** + * Handles exceptions and produces a PSR-7 ResponseInterface object. + * + * Overrides the default Yii2 exception handling to generate a PSR-7 ResponseInterface instance, supporting custom + * error views, fallback rendering, and integration with Yii2 error actions. + * + * Ensures type-safe, immutable error handling for modern runtimes. + * + * This method guarantees that all exceptions are converted to PSR-7 ResponseInterface, maintaining compatibility + * with both legacy Yii2 workflows and modern middleware stacks. + * + * @param Throwable $exception Exception to handle and convert to a PSR-7 ResponseInterface object. + * + * @return Response PSR-7 ResponseInterface representing the handled exception. + */ public function handleException($exception): Response { $this->exception = $exception; @@ -20,7 +63,13 @@ public function handleException($exception): Response $this->unregister(); if (PHP_SAPI !== 'cli') { - http_response_code(500); + $statusCode = 500; + + if ($exception instanceof HttpException) { + $statusCode = $exception->statusCode; + } + + http_response_code($statusCode); } try { @@ -40,6 +89,20 @@ public function handleException($exception): Response return $response; } + /** + * Handles fallback exception rendering when an error occurs during exception processing. + * + * Produces a {@see Response} object with a generic error message and, in debug mode, includes detailed exception + * information and a sanitized snapshot of server variables, excluding sensitive keys. + * + * This method ensures that nested or secondary exceptions do not expose sensitive data and provides a minimal + * diagnostic output for debugging purposes. + * + * @param Throwable $exception Exception thrown during error handling. + * @param Throwable $previousException Original exception that triggered error handling. + * + * @return Response Object containing the fallback error message and debug output if enabled. + */ protected function handleFallbackExceptionMessage($exception, $previousException): Response { $response = new Response(); @@ -47,22 +110,46 @@ protected function handleFallbackExceptionMessage($exception, $previousException $msg = "An Error occurred while handling another error:\n"; $msg .= $exception; + $msg .= "\nPrevious exception:\n"; + $msg .= $previousException; $response->data = 'An internal server error occurred.'; if (YII_DEBUG) { $response->data = '
' . htmlspecialchars($msg, ENT_QUOTES, Yii::$app->charset) . '
'; - $response->data .= "\n\$_SERVER = " . VarDumper::export($_SERVER); + $safeServerVars = array_diff_key( + $_SERVER, + array_flip( + [ + 'API_KEY', + 'AUTH_TOKEN', + 'DB_PASSWORD', + 'SECRET_KEY', + ], + ), + ); + $response->data .= "\n\$_SERVER = " . VarDumper::export($safeServerVars); } return $response; } /** - * @throws Exception - * @throws InvalidRouteException + * Renders the exception and produces a {@see Response} object with appropriate error content. + * + * Handles exception rendering for HTML, raw, and array formats, supporting custom error views and error actions. + * + * This method ensures type-safe, immutable error handling and maintains compatibility with Yii2 error actions and + * view rendering. + * + * @param Throwable $exception Exception to render and convert to a {@see Response} object. + * + * @throws Exception if an error occurs during error action execution. + * @throws InvalidRouteException if the error action route is invalid or cannot be resolved. + * + * @return Response Object containing the rendered exception output. */ protected function renderException($exception): Response { @@ -85,7 +172,7 @@ protected function renderException($exception): Response $response->data = '
' . $this->htmlEncode(self::convertExceptionToString($exception)) . '
'; } else { if (YII_DEBUG) { - ini_set('display_errors', 'true'); + ini_set('display_errors', '1'); } $file = $useErrorView ? $this->errorView : $this->exceptionView; diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 1ae894f1..e7656c23 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -19,7 +19,6 @@ use function function_exists; use function gc_collect_cycles; use function ini_get; -use function ini_set; use function is_string; use function memory_get_usage; use function method_exists; @@ -97,10 +96,6 @@ public function __construct(array $config = []) { $this->config = $config; - // this is necessary to get \yii\web\Session to work - ini_set('use_cookies', 'false'); - ini_set('use_only_cookies', 'true'); - $this->memoryLimit = $this->getMemoryLimit(); $this->initEventTracking(); From a77708e7ddd29c173aef43a1a8773cb4c711342d Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 27 Jul 2025 07:40:19 -0400 Subject: [PATCH 13/73] feat(tests): Add `StatelessApplicationTest` class and `SiteController` for improved testing of cookie and response handling. --- src/http/Request.php | 6 + tests/TestCase.php | 77 ++++++- tests/http/StatelessApplicationTest.php | 294 ++++++++++++++++++++++++ tests/support/FactoryHelper.php | 20 ++ tests/support/stub/SiteController.php | 130 +++++++++++ 5 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 tests/http/StatelessApplicationTest.php create mode 100644 tests/support/stub/SiteController.php diff --git a/src/http/Request.php b/src/http/Request.php index 74a51eb7..8f95a872 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -43,6 +43,12 @@ */ final class Request extends \yii\web\Request { + /** + * @var string A secret key used for cookie validation. This property must be set if {@see enableCookieValidation} + * is 'true'. + */ + public $cookieValidationKey = ''; + /** * Whether the request is in worker mode. */ diff --git a/tests/TestCase.php b/tests/TestCase.php index 65c3cd74..18385691 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,9 +4,15 @@ namespace yii2\extensions\psrbridge\tests; +use HttpSoft\Message\{ResponseFactory, StreamFactory}; +use Psr\Http\Message\{ResponseFactoryInterface, StreamFactoryInterface}; use RuntimeException; use Yii; +use yii\caching\FileCache; use yii\helpers\ArrayHelper; +use yii\log\FileTarget; +use yii\web\JsonParser; +use yii2\extensions\psrbridge\http\StatelessApplication; use function fclose; use function tmpfile; @@ -88,6 +94,75 @@ protected function createTmpFile() return $tmpFile; } + /** + * @phpstan-param array $config + */ + protected function statelessApplication($config = []): StatelessApplication + { + return new StatelessApplication( + ArrayHelper::merge( + [ + 'id' => 'stateless-app', + 'basePath' => __DIR__, + 'bootstrap' => ['log'], + 'controllerNamespace' => '\yii2\extensions\psrbridge\tests\support\stub', + 'components' => [ + 'cache' => [ + 'class' => FileCache::class, + ], + 'log' => [ + 'traceLevel' => YII_DEBUG ? 3 : 0, + 'targets' => [ + [ + 'class' => FileTarget::class, + 'levels' => [ + 'error', + 'info', + 'warning', + ], + 'logFile' => '@runtime/logs/app.log', + ], + ], + ], + 'request' => [ + 'enableCookieValidation' => false, + 'enableCsrfCookie' => false, + 'enableCsrfValidation' => false, + 'parsers' => [ + 'application/json' => JsonParser::class, + ], + 'scriptFile' => __DIR__ . '/index.php', + 'scriptUrl' => '/index.php', + ], + 'response' => [ + 'charset' => 'UTF-8', + ], + 'urlManager' => [ + 'showScriptName' => false, + 'enableStrictParsing' => false, + 'enablePrettyUrl' => true, + 'rules' => [ + [ + 'pattern' => '///', + 'route' => '/', + ], + ], + ], + ], + 'container' => [ + 'definitions' => [ + ResponseFactoryInterface::class => ResponseFactory::class, + StreamFactoryInterface::class => StreamFactory::class, + ], + ], + 'runtimePath' => dirname(__DIR__) . '/runtime', + 'vendorPath' => dirname(__DIR__) . '/vendor', + ], + $config, + ), + ); + } + /** * @phpstan-param array $config */ @@ -106,9 +181,9 @@ protected function webApplication($config = []): void 'components' => [ 'request' => [ 'cookieValidationKey' => 'wefJDF8sfdsfSDefwqdxj9oq', + 'isConsoleRequest' => false, 'scriptFile' => __DIR__ . '/index.php', 'scriptUrl' => '/index.php', - 'isConsoleRequest' => false, ], ], ], diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php new file mode 100644 index 00000000..381c0655 --- /dev/null +++ b/tests/http/StatelessApplicationTest.php @@ -0,0 +1,294 @@ +closeApplication(); + + parent::tearDown(); + } + + public function testReturnCookiesHeadersForSiteCookieRoute(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/cookie', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/cookie' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 200, + $response->getStatusCode(), + "Response status code should be '200' for 'site/cookie' route in 'StatelessApplication'.", + ); + + $cookies = $response->getHeaders()['set-cookie'] ?? []; + + foreach ($cookies as $i => $cookie) { + // Skip the last cookie header (assumed to be 'PHPSESSION'). + if ((int) $i + 1 === count($cookies)) { + continue; + } + + $params = explode('; ', $cookie); + + self::assertTrue( + in_array( + $params[0], + [ + 'test=test', + 'test2=test2', + ], + true, + ), + sprintf( + "Cookie header should contain either 'test=test' or 'test2=test2', got '%s' for 'site/cookie' " . + 'route.', + $params[0], + ), + ); + } + } + + public function testReturnJsonResponseWithCookiesForSiteGetCookiesRoute(): void + { + $_COOKIE = [ + 'test' => 'test', + ]; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/getcookies', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/getcookies' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 200, + $response->getStatusCode(), + "Response status code should be '200' for 'site/getcookies' route in 'StatelessApplication'.", + ); + self::assertSame( + <<getBody()->getContents(), + "Response body should match expected JSON string for cookie 'test' on 'site/getcookies' route in " . + "'StatelessApplication'.", + ); + } + + public function testReturnJsonResponseWithPostParametersForSitePostRoute(): void + { + $_POST = [ + 'foo' => 'bar', + 'a' => [ + 'b' => 'c', + ], + ]; + $_SERVER = [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => 'site/post', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/post' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 200, + $response->getStatusCode(), + "Response status code should be '200' for 'site/post' route in 'StatelessApplication'.", + ); + self::assertSame( + '{"foo":"bar","a":{"b":"c"}}', + $response->getBody()->getContents(), + "Response body should match expected JSON string '{\"foo\":\"bar\",\"a\":{\"b\":\"c\"}}' for 'site/post'" . + "route in 'StatelessApplication'.", + ); + } + + public function testReturnJsonResponseWithQueryParametersForSiteGetRoute(): void + { + $_GET = [ + 'foo' => 'bar', + 'a' => [ + 'b' => 'c', + ], + ]; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/get', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/get' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 200, + $response->getStatusCode(), + "Response status code should be '200' for 'site/get' route in 'StatelessApplication'.", + ); + self::assertSame( + '{"foo":"bar","a":{"b":"c"}}', + $response->getBody()->getContents(), + "Response body should match expected JSON string '{\"foo\":\"bar\",\"a\":{\"b\":\"c\"}}' for 'site/get' " . + "route in 'StatelessApplication'.", + ); + } + + public function testReturnRedirectResponseForSiteRedirectRoute(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/redirect', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/redirect' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 302, + $response->getStatusCode(), + "Response status code should be '302' for redirect route 'site/redirect' in 'StatelessApplication'.", + ); + self::assertSame( + '/site/index', + $response->getHeaders()['location'][0] ?? '', + "Response 'location' header should be '/site/index' for redirect route 'site/redirect' in " . + "'StatelessApplication'.", + ); + } + + public function testReturnRedirectResponseForSiteRefreshRoute(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/refresh', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/refresh' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 302, + $response->getStatusCode(), + "Response status code should be '302' for redirect route 'site/refresh' in 'StatelessApplication'.", + ); + self::assertSame( + 'site/refresh#stateless', + $response->getHeaders()['location'][0] ?? '', + "Response 'location' header should be 'site/refresh#stateless' for redirect route 'site/refresh' in " . + "'StatelessApplication'.", + ); + } + + public function testReturnsJsonResponse(): void + { + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when 'handled()' by 'StatelessApplication'.", + ); + self::assertSame( + 200, + $response->getStatusCode(), + "Response status code should be '200' for successful 'StatelessApplication' handling.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for JSON output.", + ); + + $body = $response->getBody()->getContents(); + + self::assertSame( + '{"hello":"world"}', + $body, + 'Response body should match expected JSON string "{\"hello\":\"world\"}".', + ); + } + + public function testReturnsStatusCode201ForSiteStatuscodeRoute(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/statuscode', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/statuscode' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 201, + $response->getStatusCode(), + "Response status code should be '201' for 'site/statuscode' route in 'StatelessApplication'.", + ); + } +} diff --git a/tests/support/FactoryHelper.php b/tests/support/FactoryHelper.php index c01cb3ac..c7ed1350 100644 --- a/tests/support/FactoryHelper.php +++ b/tests/support/FactoryHelper.php @@ -25,6 +25,7 @@ UploadedFileInterface, UriInterface, }; +use yii2\extensions\psrbridge\creator\ServerRequestCreator; use function parse_str; @@ -140,6 +141,25 @@ public static function createResponseFactory(): ResponseFactoryInterface return new ResponseFactory(); } + /** + * Creates a PSR-17 {@see ServerRequestCreator} instance. + * + * @return ServerRequestCreator PSR-17 server request creator instance. + * + * Usage example: + * ```php + * FactoryHelper::createServerRequestCreator(); + * ``` + */ + public static function createServerRequestCreator(): ServerRequestCreator + { + return new ServerRequestCreator( + FactoryHelper::createServerRequestFactory(), + FactoryHelper::createStreamFactory(), + FactoryHelper::createUploadedFileFactory(), + ); + } + /** * Creates a PSR-17 {@see ServerRequestFactory} instance. * diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php new file mode 100644 index 00000000..076d4963 --- /dev/null +++ b/tests/support/stub/SiteController.php @@ -0,0 +1,130 @@ +response; + $response->format = Response::FORMAT_JSON; + return [ + 'username' => Yii::$app->request->getAuthUser(), + 'password' => Yii::$app->request->getAuthPassword(), + ]; + } + + public function actionCookie(): void + { + $this->response->cookies->add( + new Cookie( + [ + 'name' => 'test', + 'value' => 'test', + 'httpOnly' => false, + ], + ), + ); + + $this->response->cookies->add( + new Cookie( + [ + 'name' => 'test2', + 'value' => 'test2', + ], + ), + ); + } + + public function actionFile() + { + $response = Yii::$app->response; + $response->format = Response::FORMAT_RAW; + return $response->sendFile(__DIR__ . '/../.rr.yaml', '.rr.yaml', [ + 'mimeType' => 'text/yaml', + ]); + } + + public function actionGeneralException() + { + throw new \Exception('General Exception'); + } + + public function actionGet() + { + $response = Yii::$app->response; + $response->format = Response::FORMAT_JSON; + return Yii::$app->request->get(); + } + + public function actionGetcookies(): CookieCollection + { + $this->response->format = Response::FORMAT_JSON; + + return $this->request->getCookies(); + } + + /** + * @phpstan-return string[] + */ + public function actionIndex(): array + { + $this->response->format = Response::FORMAT_JSON; + + return ['hello' => 'world']; + } + + public function actionPost(): mixed + { + $this->response->format = Response::FORMAT_JSON; + + return $this->request->post(); + } + + public function actionQuery($test) + { + $response = Yii::$app->response; + $response->format = Response::FORMAT_JSON; + return [ + 'test' => $test, + 'q' => Yii::$app->request->get('q'), + 'queryParams' => Yii::$app->request->getQueryParams(), + ]; + } + + public function actionRedirect(): void + { + $this->response->redirect('/site/index'); + } + + public function actionRefresh(): void + { + $this->response->refresh('#stateless'); + } + + public function actionStatuscode(): void + { + $this->response->statusCode = 201; + } + + public function actionStream() + { + $response = Yii::$app->response; + $response->format = Response::FORMAT_RAW; + if ($stream = fopen(__DIR__ . '/../.rr.yaml', 'r')) { + return $response->sendStreamAsFile($stream, '.rr.yaml', [ + 'mimeType' => 'text/yaml', + ]); + } + } +} From f33e2ef0d9fdb1d4f5712f7e8dd83c974cc9511b Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sun, 27 Jul 2025 11:41:12 +0000 Subject: [PATCH 14/73] Apply fixes from StyleCI --- tests/TestCase.php | 4 ++-- tests/support/FactoryHelper.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index 18385691..73eca2f7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -143,8 +143,8 @@ protected function statelessApplication($config = []): StatelessApplication 'enablePrettyUrl' => true, 'rules' => [ [ - 'pattern' => '///', - 'route' => '/', + 'pattern' => '///', + 'route' => '/', ], ], ], diff --git a/tests/support/FactoryHelper.php b/tests/support/FactoryHelper.php index c7ed1350..57c29572 100644 --- a/tests/support/FactoryHelper.php +++ b/tests/support/FactoryHelper.php @@ -154,9 +154,9 @@ public static function createResponseFactory(): ResponseFactoryInterface public static function createServerRequestCreator(): ServerRequestCreator { return new ServerRequestCreator( - FactoryHelper::createServerRequestFactory(), - FactoryHelper::createStreamFactory(), - FactoryHelper::createUploadedFileFactory(), + self::createServerRequestFactory(), + self::createStreamFactory(), + self::createUploadedFileFactory(), ); } From 03b49bf81c70f493f81cb0156adc22eb67fbb621 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 27 Jul 2025 07:41:20 -0400 Subject: [PATCH 15/73] fix(tests): Update log file path and disable auto-login in test configuration. --- tests/TestCase.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index 18385691..871cecd2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -120,7 +120,7 @@ protected function statelessApplication($config = []): StatelessApplication 'info', 'warning', ], - 'logFile' => '@runtime/logs/app.log', + 'logFile' => '@runtime/log/app.log', ], ], ], @@ -137,6 +137,9 @@ protected function statelessApplication($config = []): StatelessApplication 'response' => [ 'charset' => 'UTF-8', ], + 'user' => [ + 'enableAutoLogin' => false, + ], 'urlManager' => [ 'showScriptName' => false, 'enableStrictParsing' => false, From e408b541c92f05037660badb401710c41825768e Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 27 Jul 2025 14:53:05 -0400 Subject: [PATCH 16/73] Add more tests. --- src/http/Request.php | 69 +++++ tests/http/ErrorHandlerTest.php | 392 ++++++++++++++++++++++++ tests/http/StatelessApplicationTest.php | 212 ++++++++++++- tests/provider/RequestProvider.php | 4 +- tests/support/stub/SiteController.php | 60 ++-- 5 files changed, 711 insertions(+), 26 deletions(-) create mode 100644 tests/http/ErrorHandlerTest.php diff --git a/src/http/Request.php b/src/http/Request.php index 8f95a872..a5738d30 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -62,6 +62,75 @@ final class Request extends \yii\web\Request */ private ServerRequestAdapter|null $adapter = null; + /** + * Retrieves HTTP Basic authentication credentials from the current request. + * + * Returns an array containing the username and password sent via HTTP authentication, supporting both standard PHP + * SAPI variables and the 'Authorization' header for environments where credentials are not passed directly. + * + * The method first checks for credentials in the PHP_AUTH_USER and PHP_AUTH_PW server variables. If not present, it + * attempts to extract and decode credentials from the 'Authorization' header, handling Apache php-cgi scenarios and + * validating the decoded data for UTF-8 encoding and proper format. + * + * Usage example: + * ```php + * [$username, $password] = $request->getAuthCredentials(); + * ``` + * + * @return array Contains exactly two elements. + * - 0: username sent via HTTP authentication, `null` if the username is not given. + * - 1: password sent via HTTP authentication, `null` if the password is not given. + * + * @phpstan-return array{0: string|null, 1: string|null} + */ + public function getAuthCredentials(): array + { + $username = isset($_SERVER['PHP_AUTH_USER']) && is_string($_SERVER['PHP_AUTH_USER']) + ? $_SERVER['PHP_AUTH_USER'] + : null; + $password = isset($_SERVER['PHP_AUTH_PW']) && is_string($_SERVER['PHP_AUTH_PW']) + ? $_SERVER['PHP_AUTH_PW'] + : null; + + if ($username !== null || $password !== null) { + return [$username, $password]; + } + + /** + * Apache with php-cgi does not pass HTTP Basic authentication to PHP by default. + * To make it work, add one of the following lines to to your .htaccess file: + * + * SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0 + * --OR-- + * RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + */ + $auth_token = $this->getHeaders()->get('Authorization'); + + /** @phpstan-ignore-next-line */ + if ($auth_token !== null && strncasecmp($auth_token, 'basic', 5) === 0) { + $encoded = mb_substr($auth_token, 6); + $decoded = base64_decode($encoded, true); // strict mode + + // validate decoded data + if ($decoded === false || !mb_check_encoding($decoded, 'UTF-8')) { + return [null, null]; // return null for malformed credentials + } + + $parts = explode(':', $decoded, 2); + + if (count($parts) < 2) { + return [strlen($parts[0]) === 0 ? null : $parts[0], null]; + } + + return [ + strlen($parts[0]) === 0 ? null : $parts[0], + (isset($parts[1]) && strlen($parts[1]) !== 0) ? $parts[1] : null, + ]; + } + + return [null, null]; + } + /** * Retrieves the request body parameters, excluding the HTTP method override parameter if present. * diff --git a/tests/http/ErrorHandlerTest.php b/tests/http/ErrorHandlerTest.php new file mode 100644 index 00000000..5b5efa61 --- /dev/null +++ b/tests/http/ErrorHandlerTest.php @@ -0,0 +1,392 @@ +discardExistingOutput = false; + + $exception = new Exception('Test exception'); + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should return 'Response' instance after 'handlingException()'.", + ); + self::assertSame( + 500, + $response->getStatusCode(), + "Should set correct status code after 'handlingException()'.", + ); + } + + public function testHandleExceptionWithComplexMessage(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $exception = new Exception('Complex exception with special chars: <>&"\''); + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should 'handleException()' with complex message.", + ); + self::assertSame( + 500, + $response->getStatusCode(), + 'Should set correct status code for complex exception.', + ); + self::assertIsString( + $response->data, + 'Should set response data as string for complex exception.', + ); + } + + public function testHandleExceptionWithEmptyMessage(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $exception = new Exception(''); + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should 'handleException()' with empty message.", + ); + self::assertSame( + 500, + $response->getStatusCode(), + "Should set status code to '500' for 'Exception' with empty message.", + ); + self::assertNotNull( + $response->data, + "Should set response data even for 'Exception' with empty message.", + ); + } + public function testHandleExceptionWithGenericException(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $exception = new Exception('Generic test exception'); + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should return instance of custom 'Response' class.", + ); + self::assertSame( + 500, + $response->getStatusCode(), + "Should set status code to '500' for generic exception.", + ); + self::assertNotEmpty( + $response->data, + 'Should set response data with exception information.', + ); + } + + public function testHandleExceptionWithHttpException(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $exception = new HttpException(404, 'Page not found'); + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should return instance of custom 'Response' class for 'HTTPException'.", + ); + self::assertSame( + 404, + $response->getStatusCode(), + "Should preserve HTTP status code from 'HttpException'.", + ); + self::assertNotEmpty( + $response->data, + 'Should set response data for HTTP exception.', + ); + } + + public function testHandleExceptionWithLongMessage(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $longMessage = str_repeat('This is a very long error message. ', 100); + + $exception = new Exception($longMessage); + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should 'handleException()' with very long message.", + ); + self::assertSame( + 500, + $response->getStatusCode(), + "Should set correct status code for 'Exception' with long message.", + ); + self::assertNotEmpty( + $response->data, + "Should set response data for 'Exception' with long message.", + ); + } + + public function testHandleExceptionWithMultipleDifferentExceptions(): void + { + $exceptions = [ + new Exception('First exception'), + new RuntimeException('Second exception'), + new HttpException(400, 'Bad request'), + new UserException('User error'), + ]; + + foreach ($exceptions as $index => $exception) { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should return 'Response' instance for exceptions {$index}.", + ); + + if ($exception instanceof HttpException) { + self::assertSame( + $exception->statusCode, + $response->getStatusCode(), + "Should preserve HTTP status code for 'HttpException' {$index}.", + ); + } else { + self::assertSame( + 500, + $response->getStatusCode(), + "Should set status code to '500' for non 'HTTPException' {$index}.", + ); + } + self::assertNotEmpty( + $response->data, + "Should set response data for exceptions {$index}.", + ); + } + } + + public function testHandleExceptionWithNestedExceptions(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $innerException = new RuntimeException('Inner exception'); + $outerException = new Exception('Outer exception', 0, $innerException); + + $response = $errorHandler->handleException($outerException); + + self::assertInstanceOf( + Response::class, + $response, + 'Should handle nested exceptions.', + ); + self::assertSame( + 500, + $response->getStatusCode(), + "Should set status code to '500' for nested exceptions.", + ); + self::assertNotEmpty( + $response->data, + 'Should set response data for nested exceptions.', + ); + } + + public function testHandleExceptionWithRuntimeException(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $exception = new RuntimeException('Runtime test exception'); + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should return instance of custom 'Response' class for RuntimeException.", + ); + self::assertSame( + 500, + $response->getStatusCode(), + "Should set status code to '500' for RuntimeException.", + ); + self::assertNotEmpty( + $response->data, + 'Should set response data for runtime exception.', + ); + } + + public function testHandleExceptionWithSpecialCharactersInTrace(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + try { + throw new Exception('Test with '); + } catch (Throwable $exception) { + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should 'handleException()' with special characters in trace.", + ); + self::assertSame( + 500, + $response->getStatusCode(), + "Should set correct status code for 'Exception' with special trace.", + ); + self::assertIsString( + $response->data, + "Should set response data as string for 'Exception' with special trace.", + ); + } + } + + public function testHandleExceptionWithUserException(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $exception = new UserException('User-friendly error message'); + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should return instance of custom 'Response' class for 'UserException'.", + ); + self::assertSame( + 500, + $response->getStatusCode(), + "Should set status code to '500' for 'UserException'.", + ); + self::assertNotEmpty( + $response->data, + 'Should set response data for user exception.', + ); + } + + public function testHandleExceptionWithZeroCode(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $exception = new Exception('Exception with zero code', 0); + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should 'handleException()' with zero error code.", + ); + self::assertSame( + 500, + $response->getStatusCode(), + "Should set status code to '500' for 'Exception' with zero code.", + ); + self::assertNotEmpty( + $response->data, + "Should set response data for 'Exception' with zero code.", + ); + } + + public function testResponseDataIsNotEmpty(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $exception = new Exception('Test exception for data validation'); + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should return 'Response' instance.", + ); + self::assertNotEmpty( + $response->data, + 'Should always set non-empty response data.', + ); + self::assertIsString( + $response->data, + "'Response' data should be string.", + ); + } + + public function testResponseFormatDefaultsToHtml(): void + { + $errorHandler = new ErrorHandler(); + + $errorHandler->discardExistingOutput = false; + + $exception = new Exception('Test exception for format validation'); + + $response = $errorHandler->handleException($exception); + + self::assertInstanceOf( + Response::class, + $response, + "Should return 'Response' instance.", + ); + self::assertSame( + Response::FORMAT_HTML, + $response->format, + 'Should default to HTML format.', + ); + } +} diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 381c0655..4d6f80a7 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -70,6 +70,31 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void } } + public function testReturnInternalServerErrorResponseForGeneralExceptionRoute(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/general-exception', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/general-exception' route " . + "in 'StatelessApplication'.", + ); + self::assertSame( + 500, + $response->getStatusCode(), + "Response status code should be '500' for unhandled exception on 'site/general-exception' route in " . + "'StatelessApplication'.", + ); + } + public function testReturnJsonResponseWithCookiesForSiteGetCookiesRoute(): void { $_COOKIE = [ @@ -105,6 +130,73 @@ public function testReturnJsonResponseWithCookiesForSiteGetCookiesRoute(): void ); } + public function testReturnJsonResponseWithCredentialsForSiteAuthRoute(): void + { + $_SERVER = [ + 'HTTP_AUTHORIZATION' => 'Basic ' . base64_encode('admin:admin'), + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/auth', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/auth' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 200, + $response->getStatusCode(), + "Response status code should be '200' for 'site/auth' route in 'StatelessApplication'.", + ); + self::assertSame( + <<getBody()->getContents(), + "Response body should match expected JSON string '{\"username\":\"admin\",\"password\":\"admin\"}' " . + "for 'site/auth' route in 'StatelessApplication'.", + ); + } + + public function testReturnJsonResponseWithNullCredentialsForMalformedAuthorizationHeader(): void + { + $_SERVER = [ + 'HTTP_authorization' => 'Basic foo:bar', + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/auth', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/auth' route with malformed " . + "authorization header in 'StatelessApplication'.", + ); + self::assertSame( + 200, + $response->getStatusCode(), + "Response status code should be '200' for 'site/auth' route with malformed authorization header in " . + "'StatelessApplication'.", + ); + self::assertSame( + <<getBody()->getContents(), + "Response body should match expected JSON string '{\"username\":null,\"password\":null}' for malformed " . + "authorization header on 'site/auth' route in 'StatelessApplication'.", + ); + } + public function testReturnJsonResponseWithPostParametersForSitePostRoute(): void { $_POST = [ @@ -134,7 +226,9 @@ public function testReturnJsonResponseWithPostParametersForSitePostRoute(): void "Response status code should be '200' for 'site/post' route in 'StatelessApplication'.", ); self::assertSame( - '{"foo":"bar","a":{"b":"c"}}', + <<getBody()->getContents(), "Response body should match expected JSON string '{\"foo\":\"bar\",\"a\":{\"b\":\"c\"}}' for 'site/post'" . "route in 'StatelessApplication'.", @@ -170,13 +264,123 @@ public function testReturnJsonResponseWithQueryParametersForSiteGetRoute(): void "Response status code should be '200' for 'site/get' route in 'StatelessApplication'.", ); self::assertSame( - '{"foo":"bar","a":{"b":"c"}}', + <<getBody()->getContents(), "Response body should match expected JSON string '{\"foo\":\"bar\",\"a\":{\"b\":\"c\"}}' for 'site/get' " . "route in 'StatelessApplication'.", ); } + public function testReturnNotFoundResponseForSite404Route(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/404', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/404' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 404, + $response->getStatusCode(), + "Response status code should be '404' for 'site/404' route in 'StatelessApplication'.", + ); + self::assertSame( + 'Not Found', + $response->getReasonPhrase(), + "Response reason phrase should be 'Not Found' for 'site/404' route in 'StatelessApplication'.", + ); + } + + public function testReturnPlainTextFileResponseForSiteFileRoute(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/file', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/file' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 200, + $response->getStatusCode(), + "Response status code should be '200' for 'site/file' route in 'StatelessApplication'.", + ); + + $body = $response->getBody()->getContents(); + + self::assertSame( + 'text/plain', + $response->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'text/plain' for 'site/file' route in 'StatelessApplication'.", + ); + self::assertSame( + 'This is a test file content.', + $body, + "Response body should match expected plain text 'This is a test file content.' for 'site/file' route " . + "in 'StatelessApplication'.", + ); + self::assertSame( + 'attachment; filename="testfile.txt"', + $response->getHeaders()['content-disposition'][0] ?? '', + "Response 'content-disposition' should be 'attachment; filename=\"testfile.txt\"' for 'site/file' route " . + "in 'StatelessApplication'.", + ); + } + + public function testReturnPlainTextResponseWithFileContentForSiteStreamRoute(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/stream', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $this->statelessApplication()->handle($request); + + self::assertInstanceOf( + ResponseInterface::class, + $response, + "Response should be an instance of 'ResponseInterface' when handling 'site/stream' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 200, + $response->getStatusCode(), + "Response status code should be '200' for 'site/stream' route in 'StatelessApplication'.", + ); + self::assertSame( + 'text/plain', + $response->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'text/plain' for 'site/stream' route in 'StatelessApplication'.", + ); + self::assertSame( + 'This is a test file content.', + $response->getBody()->getContents(), + "Response body should match expected plain text 'This is a test file content.' for 'site/stream' route " . + "in 'StatelessApplication'.", + ); + } + public function testReturnRedirectResponseForSiteRedirectRoute(): void { $_SERVER = [ @@ -262,7 +466,9 @@ public function testReturnsJsonResponse(): void $body = $response->getBody()->getContents(); self::assertSame( - '{"hello":"world"}', + <<response; - $response->format = Response::FORMAT_JSON; + $this->response->format = Response::FORMAT_JSON; + return [ - 'username' => Yii::$app->request->getAuthUser(), - 'password' => Yii::$app->request->getAuthPassword(), + 'username' => $this->request->getAuthUser(), + 'password' => $this->request->getAuthPassword(), ]; } @@ -48,16 +52,25 @@ public function actionCookie(): void public function actionFile() { - $response = Yii::$app->response; - $response->format = Response::FORMAT_RAW; - return $response->sendFile(__DIR__ . '/../.rr.yaml', '.rr.yaml', [ - 'mimeType' => 'text/yaml', - ]); + $this->response->format = Response::FORMAT_RAW; + + $tmpFile = tmpfile(); + + if ($tmpFile === false) { + throw new Exception('Failed to create temporary file'); + } + + fwrite($tmpFile, 'This is a test file content.'); + rewind($tmpFile); + + $tmpFilePath = stream_get_meta_data($tmpFile)['uri']; + + return $this->response->sendFile($tmpFilePath, 'testfile.txt', ['mimeType' => 'text/plain']); } - public function actionGeneralException() + public function actionGeneralException(): never { - throw new \Exception('General Exception'); + throw new Exception('General Exception'); } public function actionGet() @@ -117,14 +130,21 @@ public function actionStatuscode(): void $this->response->statusCode = 201; } - public function actionStream() + public function actionStream(): Response { - $response = Yii::$app->response; - $response->format = Response::FORMAT_RAW; - if ($stream = fopen(__DIR__ . '/../.rr.yaml', 'r')) { - return $response->sendStreamAsFile($stream, '.rr.yaml', [ - 'mimeType' => 'text/yaml', - ]); + $this->response->format = Response::FORMAT_RAW; + + $tmpFile = tmpfile(); + + if ($tmpFile === false) { + throw new Exception('Failed to create temporary file'); } + + fwrite($tmpFile, 'This is a test file content.'); + rewind($tmpFile); + + $tmpFilePath = stream_get_meta_data($tmpFile)['uri']; + + return $this->response->sendStreamAsFile($tmpFile, $tmpFilePath, ['mimeType' => 'text/plain']); } } From 4ac654194993bbfa5e46e223361a724c17520e03 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sun, 27 Jul 2025 18:54:16 +0000 Subject: [PATCH 17/73] Apply fixes from StyleCI --- tests/http/ErrorHandlerTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/http/ErrorHandlerTest.php b/tests/http/ErrorHandlerTest.php index 5b5efa61..2fb3beaf 100644 --- a/tests/http/ErrorHandlerTest.php +++ b/tests/http/ErrorHandlerTest.php @@ -88,6 +88,7 @@ public function testHandleExceptionWithEmptyMessage(): void "Should set response data even for 'Exception' with empty message.", ); } + public function testHandleExceptionWithGenericException(): void { $errorHandler = new ErrorHandler(); From a9a17661f86665e2007fe8f6484f6cbb6844ae25 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 27 Jul 2025 15:05:28 -0400 Subject: [PATCH 18/73] refactor(tests): Remove unused test methods and clean up `SiteController` class actions. --- tests/http/StatelessApplicationTest.php | 54 ------------------------- tests/support/stub/SiteController.php | 34 +++------------- 2 files changed, 5 insertions(+), 83 deletions(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 4d6f80a7..2f0b9847 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -70,31 +70,6 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void } } - public function testReturnInternalServerErrorResponseForGeneralExceptionRoute(): void - { - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/general-exception', - ]; - - $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); - - $response = $this->statelessApplication()->handle($request); - - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/general-exception' route " . - "in 'StatelessApplication'.", - ); - self::assertSame( - 500, - $response->getStatusCode(), - "Response status code should be '500' for unhandled exception on 'site/general-exception' route in " . - "'StatelessApplication'.", - ); - } - public function testReturnJsonResponseWithCookiesForSiteGetCookiesRoute(): void { $_COOKIE = [ @@ -273,35 +248,6 @@ public function testReturnJsonResponseWithQueryParametersForSiteGetRoute(): void ); } - public function testReturnNotFoundResponseForSite404Route(): void - { - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/404', - ]; - - $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); - - $response = $this->statelessApplication()->handle($request); - - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/404' route in " . - "'StatelessApplication'.", - ); - self::assertSame( - 404, - $response->getStatusCode(), - "Response status code should be '404' for 'site/404' route in 'StatelessApplication'.", - ); - self::assertSame( - 'Not Found', - $response->getReasonPhrase(), - "Response reason phrase should be 'Not Found' for 'site/404' route in 'StatelessApplication'.", - ); - } - public function testReturnPlainTextFileResponseForSiteFileRoute(): void { $_SERVER = [ diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index 5c51172a..7e3f739a 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -4,17 +4,11 @@ namespace yii2\extensions\psrbridge\tests\support\stub; -use Yii; use yii\base\Exception; -use yii\web\{Controller, Cookie, CookieCollection, HttpException, Response}; +use yii\web\{Controller, Cookie, CookieCollection, Response}; final class SiteController extends Controller { - public function action404(): never - { - throw new HttpException(404); - } - /** * @phpstan-return array{password: string|null, username: string|null} */ @@ -50,7 +44,7 @@ public function actionCookie(): void ); } - public function actionFile() + public function actionFile(): Response { $this->response->format = Response::FORMAT_RAW; @@ -67,17 +61,11 @@ public function actionFile() return $this->response->sendFile($tmpFilePath, 'testfile.txt', ['mimeType' => 'text/plain']); } - - public function actionGeneralException(): never + public function actionGet(): mixed { - throw new Exception('General Exception'); - } + $this->response->format = Response::FORMAT_JSON; - public function actionGet() - { - $response = Yii::$app->response; - $response->format = Response::FORMAT_JSON; - return Yii::$app->request->get(); + return $this->request->get(); } public function actionGetcookies(): CookieCollection @@ -103,18 +91,6 @@ public function actionPost(): mixed return $this->request->post(); } - - public function actionQuery($test) - { - $response = Yii::$app->response; - $response->format = Response::FORMAT_JSON; - return [ - 'test' => $test, - 'q' => Yii::$app->request->get('q'), - 'queryParams' => Yii::$app->request->getQueryParams(), - ]; - } - public function actionRedirect(): void { $this->response->redirect('/site/index'); From bdc9b1ab59aa6e3a3db8f311bb8f2b0e930f1fa3 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sun, 27 Jul 2025 19:06:04 +0000 Subject: [PATCH 19/73] Apply fixes from StyleCI --- tests/support/stub/SiteController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index 7e3f739a..cac8e669 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -61,6 +61,7 @@ public function actionFile(): Response return $this->response->sendFile($tmpFilePath, 'testfile.txt', ['mimeType' => 'text/plain']); } + public function actionGet(): mixed { $this->response->format = Response::FORMAT_JSON; @@ -91,6 +92,7 @@ public function actionPost(): mixed return $this->request->post(); } + public function actionRedirect(): void { $this->response->redirect('/site/index'); From d83e98783af422f1449a83896df86bb0c96fc8c3 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 27 Jul 2025 15:09:26 -0400 Subject: [PATCH 20/73] fix(dependencies): Ensure `ext-mbstring` is required before `psr/http-message` in `composer.json`. --- .github/workflows/build.yml | 2 ++ composer.json | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a1295cb3..8a11d7c0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,5 +20,7 @@ name: build jobs: phpunit: uses: php-forge/actions/.github/workflows/phpunit.yml@main + with: + extensions: mbstring secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/composer.json b/composer.json index 99766f7e..df42e03b 100644 --- a/composer.json +++ b/composer.json @@ -12,8 +12,9 @@ "prefer-stable": true, "require": { "php": ">=8.1", - "psr/http-message": "^2.0", + "ext-mbstring": "*", "psr/http-factory": "^1.0", + "psr/http-message": "^2.0", "psr/http-server-handler": "^1.0", "yiisoft/yii2": "^2.0.53|^22" }, From d2cfb4411ab577e68af6f80a076f6598643301c3 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 27 Jul 2025 15:28:39 -0400 Subject: [PATCH 21/73] test: Enhance `ServerRequestCreatorTest` class with additional assertions and body stream handling. --- tests/creator/ServerRequestCreatorTest.php | 122 ++++++++++++++++++--- 1 file changed, 106 insertions(+), 16 deletions(-) diff --git a/tests/creator/ServerRequestCreatorTest.php b/tests/creator/ServerRequestCreatorTest.php index 8f3eb98a..887b11ae 100644 --- a/tests/creator/ServerRequestCreatorTest.php +++ b/tests/creator/ServerRequestCreatorTest.php @@ -13,8 +13,12 @@ use yii2\extensions\psrbridge\tests\support\FactoryHelper; use yii2\extensions\psrbridge\tests\TestCase; +use function fclose; +use function is_resource; use function stream_get_meta_data; +use const UPLOAD_ERR_OK; + #[Group('http')] #[Group('creator')] final class ServerRequestCreatorTest extends TestCase @@ -130,6 +134,50 @@ public function createStreamFromResource($resource): StreamInterface ); } + public function testCreateFromGlobalsWithBodyStreamSuccessfulAttachment(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/test-body-attachment'; + + $creator = new ServerRequestCreator( + FactoryHelper::createServerRequestFactory(), + FactoryHelper::createStreamFactory(), + FactoryHelper::createUploadedFileFactory(), + ); + + $request = $creator->createFromGlobals(); + $body = $request->getBody(); + + self::assertTrue( + $body->isReadable(), + 'Body stream should be readable when successfully attached.', + ); + + $streamResource = $body->detach(); + + if ($streamResource !== null) { + $metadata = stream_get_meta_data($streamResource); + + self::assertSame( + 'php://input', + $metadata['uri'], + "Body stream should have 'php://input' as URI when successfully attached from globals.", + ); + + self::assertSame( + 'rb', + $metadata['mode'], + "Body stream should have 'rb' mode when successfully attached from php://input.", + ); + + if (is_resource($streamResource)) { + fclose($streamResource); + } + } else { + self::fail("Body stream should have a valid resource when successfully attached from 'php://input'."); + } + } + public function testCreateFromGlobalsWithCaseInsensitiveHttpPrefix(): void { $_SERVER = [ @@ -249,7 +297,7 @@ public function testCreateFromGlobalsWithComplexScenario(): void $_GET['force'] = 'true'; $_FILES = [ 'photo' => [ - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, 'name' => 'avatar.png', 'size' => 1500, 'tmp_name' => $tmpPath, @@ -707,8 +755,8 @@ public function testCreateFromGlobalsWithMultipleUploadedFiles(): void $tmpPath2, ], 'error' => [ - \UPLOAD_ERR_OK, - \UPLOAD_ERR_OK, + UPLOAD_ERR_OK, + UPLOAD_ERR_OK, ], 'size' => [ 2048, @@ -936,7 +984,10 @@ public function testCreateFromGlobalsWithNoUploadedFiles(): void $request = $creator->createFromGlobals(); - self::assertEmpty($request->getUploadedFiles(), "Should have empty 'uploaded files' when '\$_FILES' is empty."); + self::assertEmpty( + $request->getUploadedFiles(), + "Should have empty 'uploaded files' when '\$_FILES' is empty.", + ); } public function testCreateFromGlobalsWithParsedBody(): void @@ -956,11 +1007,30 @@ public function testCreateFromGlobalsWithParsedBody(): void $request = $creator->createFromGlobals(); $parsedBody = $request->getParsedBody(); - self::assertIsArray($parsedBody, "Should return 'parsed body' as array when '\$_POST' contains data."); - self::assertCount(3, $parsedBody, "Should have all fields from '\$_POST'."); - self::assertSame('30', $parsedBody['age'] ?? '', "Should preserve 'age' from '\$_POST'."); - self::assertSame('john@example.com', $parsedBody['email'] ?? '', "Should preserve 'email' from '\$_POST'."); - self::assertSame('john_doe', $parsedBody['username'] ?? '', "Should preserve 'username' from '\$_POST'."); + self::assertIsArray( + $parsedBody, + "Should return 'parsed body' as array when '\$_POST' contains data.", + ); + self::assertCount( + 3, + $parsedBody, + "Should have all fields from '\$_POST'.", + ); + self::assertSame( + '30', + $parsedBody['age'] ?? '', + "Should preserve 'age' from '\$_POST'.", + ); + self::assertSame( + 'john@example.com', + $parsedBody['email'] ?? '', + "Should preserve 'email' from '\$_POST'.", + ); + self::assertSame( + 'john_doe', + $parsedBody['username'] ?? '', + "Should preserve 'username' from '\$_POST'.", + ); } public function testCreateFromGlobalsWithQueryParams(): void @@ -981,11 +1051,31 @@ public function testCreateFromGlobalsWithQueryParams(): void $request = $creator->createFromGlobals(); $queryParams = $request->getQueryParams(); - self::assertCount(4, $queryParams, "Should have all 'query parameters' from '\$_GET'."); - self::assertSame('electronics', $queryParams['category'] ?? '', "Should preserve 'category' query parameter."); - self::assertSame('500', $queryParams['price_max'] ?? '', "Should preserve 'price_max' query parameter."); - self::assertSame('100', $queryParams['price_min'] ?? '', "Should preserve 'price_min' query parameter."); - self::assertSame('price_asc', $queryParams['sort'] ?? '', "Should preserve 'sort' query parameter."); + self::assertCount( + 4, + $queryParams, + "Should have all 'query parameters' from '\$_GET'.", + ); + self::assertSame( + 'electronics', + $queryParams['category'] ?? '', + "Should preserve 'category' query parameter.", + ); + self::assertSame( + '500', + $queryParams['price_max'] ?? '', + "Should preserve 'price_max' query parameter.", + ); + self::assertSame( + '100', + $queryParams['price_min'] ?? '', + "Should preserve 'price_min' query parameter.", + ); + self::assertSame( + 'price_asc', + $queryParams['sort'] ?? '', + "Should preserve 'sort' query parameter.", + ); } public function testCreateFromGlobalsWithServerValues(): void @@ -1041,7 +1131,7 @@ public function testCreateFromGlobalsWithSingleUploadedFile(): void 'name' => 'profile.jpg', 'type' => 'image/jpeg', 'tmp_name' => $tmpPath, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, 'size' => 1024, ]; @@ -1089,7 +1179,7 @@ public function testCreateFromGlobalsWithSingleUploadedFile(): void 'Should preserve file size from \'$_FILES\'.', ); self::assertSame( - \UPLOAD_ERR_OK, + UPLOAD_ERR_OK, $uploadedFile->getError(), 'Should preserve error code from \'$_FILES\'.', ); From e0639a4ba43d185465b6457a0f13a44a1798fdfa Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 27 Jul 2025 15:39:49 -0400 Subject: [PATCH 22/73] test: Add exception handling for maximum nesting depth in `UploadedFileCreatorTest` class. --- tests/creator/UploadedFileCreatorTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/creator/UploadedFileCreatorTest.php b/tests/creator/UploadedFileCreatorTest.php index 2539ad8c..2b977f6f 100644 --- a/tests/creator/UploadedFileCreatorTest.php +++ b/tests/creator/UploadedFileCreatorTest.php @@ -1032,6 +1032,26 @@ public function testThrowExceptionWithMismatchedSizesArray(): void $creator->createFromGlobals($files); } + public function testThrowsExceptionForDepthValidation(): void + { + $tmpFile = $this->createTmpFile(); + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + + $elevenLevelFiles = $this->createDeeplyNestedFileStructure($tmpPath, 12); + + $creator = new UploadedFileCreator( + FactoryHelper::createUploadedFileFactory(), + FactoryHelper::createStreamFactory(), + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + Message::MAXIMUM_NESTING_DEPTH_EXCEEDED->getMessage(11), + ); + + $creator->createFromGlobals($elevenLevelFiles); + } + public function testThrowsExceptionWhenMissingError(): void { $tmpFile = $this->createTmpFile(); From 1234153f96870ce02ff0b9dc4fb6b1421dea2021 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 27 Jul 2025 15:57:27 -0400 Subject: [PATCH 23/73] test: Replace backslash with constant for UPLOAD_ERR_OK in `UploadedFileCreatorTest` class --- tests/creator/UploadedFileCreatorTest.php | 171 ++++++++++++++++++---- 1 file changed, 142 insertions(+), 29 deletions(-) diff --git a/tests/creator/UploadedFileCreatorTest.php b/tests/creator/UploadedFileCreatorTest.php index 2b977f6f..47219eb3 100644 --- a/tests/creator/UploadedFileCreatorTest.php +++ b/tests/creator/UploadedFileCreatorTest.php @@ -14,6 +14,8 @@ use function stream_get_meta_data; +use const UPLOAD_ERR_OK; + #[Group('http')] #[Group('creator')] final class UploadedFileCreatorTest extends TestCase @@ -26,7 +28,7 @@ public function testCreateFromArrayWithMinimalFileSpec(): void $fileSpec = [ 'tmp_name' => $tmpPath, 'size' => 512, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, ]; $creator = new UploadedFileCreator( @@ -50,7 +52,7 @@ public function testCreateFromArrayWithMinimalFileSpec(): void "Should preserve 'file size' from minimal specification.", ); self::assertSame( - \UPLOAD_ERR_OK, + UPLOAD_ERR_OK, $uploadedFile->getError(), "Should preserve 'error code' from minimal specification.", ); @@ -64,7 +66,7 @@ public function testCreateFromArrayWithNullOptionalFields(): void $fileSpec = [ 'tmp_name' => $tmpPath, 'size' => 256, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, 'name' => null, 'type' => null, ]; @@ -99,7 +101,7 @@ public function testCreateFromArrayWithValidFileSpec(): void $fileSpec = [ 'tmp_name' => $tmpPath, 'size' => 1024, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, 'name' => 'test.txt', 'type' => 'text/plain', ]; @@ -127,7 +129,7 @@ public function testCreateFromArrayWithValidFileSpec(): void "Should preserve 'file size' from file specification.", ); self::assertSame( - \UPLOAD_ERR_OK, + UPLOAD_ERR_OK, $uploadedFile->getError(), "Should preserve 'error code' from file specification.", ); @@ -152,7 +154,7 @@ public function testCreateFromGlobalsWithExistingUploadedFileInterface(): void 'existing.txt', 'text/plain', '/tmp/existing', - \UPLOAD_ERR_OK, + UPLOAD_ERR_OK, 1024, ); @@ -194,7 +196,7 @@ public function testCreateFromGlobalsWithMixedFileStructures(): void 'single' => [ 'tmp_name' => $tmpPath1, 'size' => 1024, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, 'name' => 'single.txt', 'type' => 'text/plain', ], @@ -208,8 +210,8 @@ public function testCreateFromGlobalsWithMixedFileStructures(): void 1536, ], 'error' => [ - \UPLOAD_ERR_OK, - \UPLOAD_ERR_OK, + UPLOAD_ERR_OK, + UPLOAD_ERR_OK, ], 'name' => [ 'multi1.pdf', @@ -321,8 +323,8 @@ public function testCreateFromGlobalsWithMultipleFiles(): void 1536, ], 'error' => [ - \UPLOAD_ERR_OK, - \UPLOAD_ERR_OK, + UPLOAD_ERR_OK, + UPLOAD_ERR_OK, ], 'name' => [ 'doc1.txt', @@ -425,7 +427,7 @@ public function testCreateFromGlobalsWithNestedStructure(): void 'level1' => [ 'tmp_name' => $tmpPath1, 'size' => 1024, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, 'name' => 'nested1.txt', 'type' => 'text/plain', ], @@ -433,7 +435,7 @@ public function testCreateFromGlobalsWithNestedStructure(): void 'level3' => [ 'tmp_name' => [$tmpPath2], 'size' => [512], - 'error' => [\UPLOAD_ERR_OK], + 'error' => [UPLOAD_ERR_OK], 'name' => ['nested2.jpg'], 'type' => ['image/jpeg'], ], @@ -551,7 +553,7 @@ public function testCreateFromGlobalsWithSingleFile(): void 'upload' => [ 'tmp_name' => $tmpPath, 'size' => 1024, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, 'name' => 'document.pdf', 'type' => 'application/pdf', ], @@ -599,6 +601,117 @@ public function testCreateFromGlobalsWithSingleFile(): void ); } + public function testDepthParameterStartsAtZeroForRecursionValidation(): void + { + $tmpFile = $this->createTmpFile(); + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + + $files = [ + 'level_test' => [ + 'tmp_name' => [ + 'l1' => [ + 'l2' => [ + 'l3' => [ + 'l4' => [ + 'l5' => [ + 'l6' => [ + 'l7' => [ + 'l8' => [ + 'l9' => [ + 'l10' => $tmpPath, + ], + ], + ], + ], + ], + ], + ], + ], + ], + ], + 'size' => [ + 'l1' => [ + 'l2' => [ + 'l3' => [ + 'l4' => [ + 'l5' => [ + 'l6' => [ + 'l7' => [ + 'l8' => [ + 'l9' => [ + 'l10' => 1024, + ], + ], + ], + ], + ], + ], + ], + ], + ], + ], + 'error' => [ + 'l1' => [ + 'l2' => [ + 'l3' => [ + 'l4' => [ + 'l5' => [ + 'l6' => [ + 'l7' => [ + 'l8' => [ + 'l9' => [ + 'l10' => UPLOAD_ERR_OK, + ], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ]; + + $creator = new UploadedFileCreator( + FactoryHelper::createUploadedFileFactory(), + FactoryHelper::createStreamFactory(), + ); + + // this should succeed because depth starts at 0, reaching exactly depth = 10 + $result = $creator->createFromGlobals($files); + + self::assertArrayHasKey( + 'level_test', + $result, + 'Should successfully process structure that reaches exactly depth = 10.', + ); + + // navigate through the structure to verify it was processed + $current = $result['level_test'] ?? null; + + for ($i = 1; $i <= 10; $i++) { + self::assertIsArray( + $current, + "Should be array at level {$i}.", + ); + self::assertArrayHasKey( + "l{$i}", + $current, + "Should have key l{$i}.", + ); + + $current = $current["l{$i}"] ?? null; + } + + self::assertInstanceOf( + UploadedFileInterface::class, + $current, + "Should create 'UploadedFileInterface' at the deepest level when 'depth' starts at '0'.", + ); + } + public function testSuccessWithMaximumAllowedRecursionDepth(): void { $tmpFile = $this->createTmpFile(); @@ -647,7 +760,7 @@ public function testThrowExceptionInBuildFileTreeRecursion(): void ], 'error' => [ 'category' => [ - 'subcategory' => \UPLOAD_ERR_OK, + 'subcategory' => UPLOAD_ERR_OK, ], ], ], @@ -680,7 +793,7 @@ public function testThrowExceptionInBuildFileTreeWithMismatchedArrayStructureErr 'level1' => [1024], ], 'error' => [ - 'level1' => \UPLOAD_ERR_OK, + 'level1' => UPLOAD_ERR_OK, ], ], ]; @@ -712,7 +825,7 @@ public function testThrowExceptionInBuildFileTreeWithMismatchedArrayStructureSiz 'level1' => 1024, ], 'error' => [ - 'level1' => [\UPLOAD_ERR_OK], + 'level1' => [UPLOAD_ERR_OK], ], ], ]; @@ -760,7 +873,7 @@ public function testThrowExceptionWhenMissingSize(): void $fileSpec = [ 'tmp_name' => $tmpPath, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, ]; $creator = new UploadedFileCreator( @@ -781,7 +894,7 @@ public function testThrowExceptionWhenMissingTmpNameInFileSpec(): void { $fileSpec = [ 'size' => 1024, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, ]; $creator = new UploadedFileCreator( @@ -817,8 +930,8 @@ public function testThrowExceptionWhenNameIsNotArrayOrNullInMultiFileSpec(): voi 1536, ], 'error' => [ - \UPLOAD_ERR_OK, - \UPLOAD_ERR_OK, + UPLOAD_ERR_OK, + UPLOAD_ERR_OK, ], 'name' => 'not_array', // should be array or 'null' when 'tmp_name' is array ], @@ -845,7 +958,7 @@ public function testThrowExceptionWhenNameIsNotStringOrNull(): void $fileSpec = [ 'tmp_name' => $tmpPath, 'size' => 1024, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, 'name' => 123, ]; @@ -887,7 +1000,7 @@ public function testThrowExceptionWhenSizeIsNotInteger(): void $fileSpec = [ 'tmp_name' => $tmpPath, 'size' => 'invalid', - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, ]; $creator = new UploadedFileCreator( @@ -909,7 +1022,7 @@ public function testThrowExceptionWhenTmpFileDoesNotExist(): void $fileSpec = [ 'tmp_name' => $nonExistentPath, 'size' => 1024, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, 'name' => 'test.txt', 'type' => 'text/plain', ]; @@ -930,7 +1043,7 @@ public function testThrowExceptionWhenTmpNameIsNotString(): void $fileSpec = [ 'tmp_name' => 123, 'size' => 1024, - 'error' => \UPLOAD_ERR_OK, + 'error' => UPLOAD_ERR_OK, ]; $creator = new UploadedFileCreator( @@ -984,7 +1097,7 @@ public function testThrowExceptionWithMismatchedErrorsArray(): void 2048, 1536, ], - 'error' => [\UPLOAD_ERR_OK], // missing 'error code' for second file + 'error' => [UPLOAD_ERR_OK], // missing 'error code' for second file ], ]; @@ -1015,8 +1128,8 @@ public function testThrowExceptionWithMismatchedSizesArray(): void ], 'size' => [2048], // missing 'size' for second file 'error' => [ - \UPLOAD_ERR_OK, - \UPLOAD_ERR_OK, + UPLOAD_ERR_OK, + UPLOAD_ERR_OK, ], ], ]; @@ -1085,7 +1198,7 @@ public function testThrowsExceptionWithMismatchedStructures(): void 'invalid' => [ 'tmp_name' => [$tmpPath], 'size' => 'not_array', // should be array when 'tmp_name' is array - 'error' => [\UPLOAD_ERR_OK], + 'error' => [UPLOAD_ERR_OK], ], ]; From 1ae3af0214d282d97ad686dc568287b37564c88a Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 27 Jul 2025 16:36:49 -0400 Subject: [PATCH 24/73] test: Update assertions in `UploadedFileCreatorTest` for clarity on depth parameter handling. --- tests/creator/UploadedFileCreatorTest.php | 45 ++++++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/tests/creator/UploadedFileCreatorTest.php b/tests/creator/UploadedFileCreatorTest.php index 47219eb3..ac9ba0e6 100644 --- a/tests/creator/UploadedFileCreatorTest.php +++ b/tests/creator/UploadedFileCreatorTest.php @@ -679,13 +679,13 @@ public function testDepthParameterStartsAtZeroForRecursionValidation(): void FactoryHelper::createStreamFactory(), ); - // this should succeed because depth starts at 0, reaching exactly depth = 10 + // this should succeed because depth starts at 0, reaching exactly 'depth' = '10' $result = $creator->createFromGlobals($files); self::assertArrayHasKey( 'level_test', $result, - 'Should successfully process structure that reaches exactly depth = 10.', + "Should successfully process structure that reaches exactly 'depth' = '10'.", ); // navigate through the structure to verify it was processed @@ -712,6 +712,41 @@ public function testDepthParameterStartsAtZeroForRecursionValidation(): void ); } + public function testDepthParameterStartsAtZeroNotOne(): void + { + $tmpFile = $this->createTmpFile(); + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + + $tenLevelFiles = $this->createDeeplyNestedFileStructure($tmpPath, 11); + + $creator = new UploadedFileCreator( + FactoryHelper::createUploadedFileFactory(), + FactoryHelper::createStreamFactory(), + ); + + // this should succeed without throwing an exception if 'depth' starts at '0' + $result = $creator->createFromGlobals($tenLevelFiles); + + // navigate to the deepest file to verify it was processed correctly + $finalFile = $this->navigateToDeepestFile($result, 11); + + self::assertInstanceOf( + UploadedFileInterface::class, + $finalFile, + "Should successfully process exactly '10' levels when 'depth' parameter starts at '0', not '1'.", + ); + self::assertSame( + 'deep_file_level_11.txt', + $finalFile->getClientFilename(), + "Should preserve 'client filename' at exactly '10' levels when 'depth' starts at '0'.", + ); + self::assertSame( + 1024, + $finalFile->getSize(), + "Should preserve 'file size' at exactly '10' levels when 'depth' starts at '0'.", + ); + } + public function testSuccessWithMaximumAllowedRecursionDepth(): void { $tmpFile = $this->createTmpFile(); @@ -729,17 +764,17 @@ public function testSuccessWithMaximumAllowedRecursionDepth(): void self::assertInstanceOf( UploadedFileInterface::class, $finalFile, - 'Should successfully process file at maximum allowed depth of 10 levels.', + "Should successfully process file at maximum allowed 'depth' of '10' levels.", ); self::assertSame( 'deep_file_level_10.txt', $finalFile->getClientFilename(), - 'Should preserve client filename at maximum depth.', + "Should preserve 'client filename' at maximum 'depth'.", ); self::assertSame( 1024, $finalFile->getSize(), - 'Should preserve file size at maximum depth.', + "Should preserve 'file size' at maximum 'depth'.", ); } From ef1f19242a8e9e674757482226a0f63209b47027 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 27 Jul 2025 16:49:51 -0400 Subject: [PATCH 25/73] test: Refactor deeply nested file structure creation in `UploadedFileCreatorTest` class. --- tests/creator/UploadedFileCreatorTest.php | 89 +---------------------- 1 file changed, 4 insertions(+), 85 deletions(-) diff --git a/tests/creator/UploadedFileCreatorTest.php b/tests/creator/UploadedFileCreatorTest.php index ac9ba0e6..f22979e6 100644 --- a/tests/creator/UploadedFileCreatorTest.php +++ b/tests/creator/UploadedFileCreatorTest.php @@ -606,73 +606,7 @@ public function testDepthParameterStartsAtZeroForRecursionValidation(): void $tmpFile = $this->createTmpFile(); $tmpPath = stream_get_meta_data($tmpFile)['uri']; - $files = [ - 'level_test' => [ - 'tmp_name' => [ - 'l1' => [ - 'l2' => [ - 'l3' => [ - 'l4' => [ - 'l5' => [ - 'l6' => [ - 'l7' => [ - 'l8' => [ - 'l9' => [ - 'l10' => $tmpPath, - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - 'size' => [ - 'l1' => [ - 'l2' => [ - 'l3' => [ - 'l4' => [ - 'l5' => [ - 'l6' => [ - 'l7' => [ - 'l8' => [ - 'l9' => [ - 'l10' => 1024, - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - 'error' => [ - 'l1' => [ - 'l2' => [ - 'l3' => [ - 'l4' => [ - 'l5' => [ - 'l6' => [ - 'l7' => [ - 'l8' => [ - 'l9' => [ - 'l10' => UPLOAD_ERR_OK, - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ]; + $files = $this->createDeeplyNestedFileStructure($tmpPath, 10); $creator = new UploadedFileCreator( FactoryHelper::createUploadedFileFactory(), @@ -683,31 +617,16 @@ public function testDepthParameterStartsAtZeroForRecursionValidation(): void $result = $creator->createFromGlobals($files); self::assertArrayHasKey( - 'level_test', + 'deep', $result, "Should successfully process structure that reaches exactly 'depth' = '10'.", ); - // navigate through the structure to verify it was processed - $current = $result['level_test'] ?? null; - - for ($i = 1; $i <= 10; $i++) { - self::assertIsArray( - $current, - "Should be array at level {$i}.", - ); - self::assertArrayHasKey( - "l{$i}", - $current, - "Should have key l{$i}.", - ); - - $current = $current["l{$i}"] ?? null; - } + $finalFile = $this->navigateToDeepestFile($result, 10); self::assertInstanceOf( UploadedFileInterface::class, - $current, + $finalFile, "Should create 'UploadedFileInterface' at the deepest level when 'depth' starts at '0'.", ); } From 48da7672e876eaa0d26ceeb9a4bc11546e30bfde Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 27 Jul 2025 17:14:26 -0400 Subject: [PATCH 26/73] test: Remove redundant test for depth parameter starting at zero in `UploadedFileCreatorTest` class. --- tests/creator/UploadedFileCreatorTest.php | 41 ++++++----------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/tests/creator/UploadedFileCreatorTest.php b/tests/creator/UploadedFileCreatorTest.php index f22979e6..d9798ab2 100644 --- a/tests/creator/UploadedFileCreatorTest.php +++ b/tests/creator/UploadedFileCreatorTest.php @@ -601,36 +601,6 @@ public function testCreateFromGlobalsWithSingleFile(): void ); } - public function testDepthParameterStartsAtZeroForRecursionValidation(): void - { - $tmpFile = $this->createTmpFile(); - $tmpPath = stream_get_meta_data($tmpFile)['uri']; - - $files = $this->createDeeplyNestedFileStructure($tmpPath, 10); - - $creator = new UploadedFileCreator( - FactoryHelper::createUploadedFileFactory(), - FactoryHelper::createStreamFactory(), - ); - - // this should succeed because depth starts at 0, reaching exactly 'depth' = '10' - $result = $creator->createFromGlobals($files); - - self::assertArrayHasKey( - 'deep', - $result, - "Should successfully process structure that reaches exactly 'depth' = '10'.", - ); - - $finalFile = $this->navigateToDeepestFile($result, 10); - - self::assertInstanceOf( - UploadedFileInterface::class, - $finalFile, - "Should create 'UploadedFileInterface' at the deepest level when 'depth' starts at '0'.", - ); - } - public function testDepthParameterStartsAtZeroNotOne(): void { $tmpFile = $this->createTmpFile(); @@ -678,7 +648,16 @@ public function testSuccessWithMaximumAllowedRecursionDepth(): void FactoryHelper::createStreamFactory(), ); - $finalFile = $this->navigateToDeepestFile($creator->createFromGlobals($maxDepthFiles), 10); + // this should succeed because depth starts at 0, reaching exactly 'depth' = '10' + $result = $creator->createFromGlobals($maxDepthFiles); + + self::assertArrayHasKey( + 'deep', + $result, + "Should successfully process structure that reaches exactly 'depth' = '10'.", + ); + + $finalFile = $this->navigateToDeepestFile($result, 10); self::assertInstanceOf( UploadedFileInterface::class, From 2a76779a1322e4ba2b66bcedb2b741d7070c9dad Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 05:46:04 -0400 Subject: [PATCH 27/73] test: Add core components configuration test and alias setup verification in `StatelessApplicationTest` class. --- src/http/StatelessApplication.php | 11 +--- tests/http/StatelessApplicationTest.php | 83 +++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index e7656c23..40f29275 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -8,10 +8,9 @@ use Psr\Http\Server\RequestHandlerInterface; use Throwable; use Yii; -use yii\base\Event; -use yii\base\InvalidConfigException; +use yii\base\{Event, InvalidConfigException}; use yii\di\NotInstantiableException; -use yii\web\{Application, Session, UploadedFile, User}; +use yii\web\{Application, UploadedFile}; use function array_merge; use function array_reverse; @@ -163,12 +162,6 @@ public function coreComponents(): array 'response' => [ 'class' => Response::class, ], - 'session' => [ - 'class' => Session::class, - ], - 'user' => [ - 'class' => User::class, - ], ], ); } diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 2f0b9847..a0c35016 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -6,6 +6,17 @@ use PHPUnit\Framework\Attributes\Group; use Psr\Http\Message\ResponseInterface; +use Yii; +use yii\base\Security; +use yii\i18n\Formatter; +use yii\i18n\I18N; +use yii\log\Dispatcher; +use yii\web\AssetManager; +use yii\web\Session; +use yii\web\UrlManager; +use yii\web\User; +use yii\web\View; +use yii2\extensions\psrbridge\http\{ErrorHandler, Request, Response}; use yii2\extensions\psrbridge\tests\support\FactoryHelper; use yii2\extensions\psrbridge\tests\TestCase; @@ -70,6 +81,58 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void } } + public function testReturnCoreComponentsConfigurationAfterHandle(): void + { + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + $app->handle($request); + + self::assertSame( + [ + 'log' => [ + 'class' => Dispatcher::class, + ], + 'view' => [ + 'class' => View::class, + ], + 'formatter' => [ + 'class' => Formatter::class, + ], + 'i18n' => [ + 'class' => I18N::class, + ], + 'urlManager' => [ + 'class' => UrlManager::class, + ], + 'assetManager' => [ + 'class' => AssetManager::class, + ], + 'security' => [ + 'class' => Security::class, + ], + 'request' => [ + 'class' => Request::class, + ], + 'response' => [ + 'class' => Response::class, + ], + 'session' => [ + 'class' => Session::class, + ], + 'user' => [ + 'class' => User::class, + ], + 'errorHandler' => [ + 'class' => ErrorHandler::class, + ], + ], + $app->coreComponents(), + "'coreComponents()' should return the expected mapping of component IDs to class definitions after " . + "handling a request in 'StatelessApplication'.", + ); + } + public function testReturnJsonResponseWithCookiesForSiteGetCookiesRoute(): void { $_COOKIE = [ @@ -443,4 +506,24 @@ public function testReturnsStatusCode201ForSiteStatuscodeRoute(): void "Response status code should be '201' for 'site/statuscode' route in 'StatelessApplication'.", ); } + + public function testSetWebAndWebrootAliasesAfterHandleRequest(): void + { + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + $app->handle($request); + + self::assertSame( + '', + Yii::getAlias('@web'), + "'@web' alias should be set to an empty string after handling a request in 'StatelessApplication'.", + ); + self::assertSame( + dirname(__DIR__), + Yii::getAlias('@webroot'), + "'@webroot' alias should be set to the parent directory of the test directory after handling a request " . + "in 'StatelessApplication'.", + ); + } } From be2ac735036b806afee4064f1d818c7822484c9e Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 06:34:37 -0400 Subject: [PATCH 28/73] test: Add tests to verify triggering of before and after request events in `StatelessApplication` class. --- tests/http/StatelessApplicationTest.php | 44 +++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index a0c35016..59833b44 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -526,4 +526,48 @@ public function testSetWebAndWebrootAliasesAfterHandleRequest(): void "in 'StatelessApplication'.", ); } + + public function testTriggerBeforeRequestEventDuringHandle(): void + { + $beforeRequestTriggered = false; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + $app = $this->statelessApplication(); + + $app->on( + $app::EVENT_BEFORE_REQUEST, + static function () use (&$beforeRequestTriggered): void { + $beforeRequestTriggered = true; + }, + ); + + $app->handle($request); + + self::assertTrue( + $beforeRequestTriggered, + "Should trigger 'EVENT_BEFORE_REQUEST' event during 'handle()' execution in 'StatelessApplication'.", + ); + } + + public function testTriggerAfterRequestEventDuringHandle(): void + { + $afterRequestTriggered = false; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + $app = $this->statelessApplication(); + + $app->on( + $app::EVENT_AFTER_REQUEST, + static function () use (&$afterRequestTriggered): void { + $afterRequestTriggered = true; + }, + ); + + $app->handle($request); + + self::assertTrue( + $afterRequestTriggered, + "Should trigger 'EVENT_AFTER_REQUEST' event during 'handle()' execution in 'StatelessApplication'.", + ); + } } From 6b78f5cf0f11ee42b656c6b5461687a157d6bcfb Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 06:36:06 -0400 Subject: [PATCH 29/73] test: Swap before and after request event triggers in `StatelessApplicationTest` class. --- tests/http/StatelessApplicationTest.php | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 59833b44..e2a32ec2 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -527,47 +527,47 @@ public function testSetWebAndWebrootAliasesAfterHandleRequest(): void ); } - public function testTriggerBeforeRequestEventDuringHandle(): void + public function testTriggerAfterRequestEventDuringHandle(): void { - $beforeRequestTriggered = false; + $afterRequestTriggered = false; $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $app = $this->statelessApplication(); $app->on( - $app::EVENT_BEFORE_REQUEST, - static function () use (&$beforeRequestTriggered): void { - $beforeRequestTriggered = true; + $app::EVENT_AFTER_REQUEST, + static function () use (&$afterRequestTriggered): void { + $afterRequestTriggered = true; }, ); $app->handle($request); self::assertTrue( - $beforeRequestTriggered, - "Should trigger 'EVENT_BEFORE_REQUEST' event during 'handle()' execution in 'StatelessApplication'.", + $afterRequestTriggered, + "Should trigger 'EVENT_AFTER_REQUEST' event during 'handle()' execution in 'StatelessApplication'.", ); } - public function testTriggerAfterRequestEventDuringHandle(): void + public function testTriggerBeforeRequestEventDuringHandle(): void { - $afterRequestTriggered = false; + $beforeRequestTriggered = false; $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $app = $this->statelessApplication(); $app->on( - $app::EVENT_AFTER_REQUEST, - static function () use (&$afterRequestTriggered): void { - $afterRequestTriggered = true; + $app::EVENT_BEFORE_REQUEST, + static function () use (&$beforeRequestTriggered): void { + $beforeRequestTriggered = true; }, ); $app->handle($request); self::assertTrue( - $afterRequestTriggered, - "Should trigger 'EVENT_AFTER_REQUEST' event during 'handle()' execution in 'StatelessApplication'.", + $beforeRequestTriggered, + "Should trigger 'EVENT_BEFORE_REQUEST' event during 'handle()' execution in 'StatelessApplication'.", ); } } From 54625da13d31783f4a4ee9cb6dc99f2f746de41a Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 06:43:42 -0400 Subject: [PATCH 30/73] refactor: Remove redundant request handling in bootstrap method of `StatelessApplication` class. --- src/http/StatelessApplication.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 40f29275..c33bab5f 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -246,11 +246,6 @@ public function init(): void */ protected function bootstrap(): void { - $request = $this->getRequest(); - - Yii::setAlias('@webroot', dirname($request->getScriptFile())); - Yii::setAlias('@web', $request->getBaseUrl()); - parent::bootstrap(); } From 1b59b7d41f7b486e4cc512fe4ffca02f50e771a9 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Mon, 28 Jul 2025 10:44:21 +0000 Subject: [PATCH 31/73] Apply fixes from StyleCI --- src/http/StatelessApplication.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index c33bab5f..b8e72586 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -14,7 +14,6 @@ use function array_merge; use function array_reverse; -use function dirname; use function function_exists; use function gc_collect_cycles; use function ini_get; From 2a6674447bc2ed69c16ddf8b7a0c3dbabac99411 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 06:45:54 -0400 Subject: [PATCH 32/73] refactor: Remove bootstrap method and its documentation from `StatelessApplication` class. --- src/http/StatelessApplication.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index c33bab5f..6c691410 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -233,22 +233,6 @@ public function init(): void $this->state = self::STATE_INIT; } - /** - * Bootstraps the StatelessApplication by setting core path aliases and invoking parent bootstrap logic. - * - * Sets the '@webroot' and '@web' path aliases based on the current request, ensuring correct path resolution for - * asset management and routing in stateless and worker environments. - * - * This method prepares the application for request handling by configuring essential Yii2 path aliases before - * delegating to the parent bootstrap implementation. - * - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - protected function bootstrap(): void - { - parent::bootstrap(); - } - /** * Resets the StatelessApplication state and prepares the Yii2 environment for handling a PSR-7 request. * From 3184963fceee2341be3aebbc03db4cfce5545f8a Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 06:53:44 -0400 Subject: [PATCH 33/73] refactor: Remove unnecessary `ensureBehaviors()` method call in `StatelessApplication` class. --- src/http/StatelessApplication.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 2f0dd0a1..2a5f2c71 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -278,8 +278,6 @@ protected function reset(ServerRequestInterface $request): void $this->session->setId($sessionId); } - $this->ensureBehaviors(); - $this->session->open(); $this->bootstrap(); From 7ac3960202e2810702991459fd6536b74cbfe0fe Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 07:57:59 -0400 Subject: [PATCH 34/73] test: Add test for handling unlimited `memory_limit` in `StatelessApplication` class. --- tests/http/StatelessApplicationTest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index e2a32ec2..e36fb464 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -30,6 +30,29 @@ protected function tearDown(): void parent::tearDown(); } + public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void + { + $originalLimit = ini_get('memory_limit'); + + try { + ini_set('memory_limit', '-1'); + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + + $app->handle($request); + + self::assertFalse( + $app->clean(), + "Should return 'false' from 'clean()' when 'memory_limit' is unlimited ('-1'), indicating memory usage " . + 'is below threshold.', + ); + } finally { + ini_set('memory_limit', $originalLimit); + } + } + public function testReturnCookiesHeadersForSiteCookieRoute(): void { $_SERVER = [ From 3d46cbc31ac62e1b99ab06950490580584f90ab4 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 08:12:24 -0400 Subject: [PATCH 35/73] test: Add test for parsing memory limit with suffix in `StatelessApplicationTest` --- tests/http/StatelessApplicationTest.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index e36fb464..0679c04f 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -53,6 +53,30 @@ public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void } } + public function testGetMemoryLimitParsesMemoryLimitWithSuffix(): void + { + $originalLimit = ini_get('memory_limit'); + + try { + ini_set('memory_limit', '64M'); + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + $app->handle($request); + $cleanResult = $app->clean(); + + self::assertSame( + false, + $cleanResult, + "Should return boolean from 'clean()' when 'memory_limit' '64M' is properly parsed to bytes.", + ); + + } finally { + ini_set('memory_limit', $originalLimit); + } + } + public function testReturnCookiesHeadersForSiteCookieRoute(): void { $_SERVER = [ From a2d90a030c2f6b5c5bf76e0a2f18e91c357c9dd4 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 09:58:49 -0400 Subject: [PATCH 36/73] feat: Add `setMemoryLimit()` method to `StatelessApplication` class and corresponding test for recalculating `memory_limit`. --- src/http/StatelessApplication.php | 5 +++++ tests/http/StatelessApplicationTest.php | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 2a5f2c71..9a73d5ae 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -232,6 +232,11 @@ public function init(): void $this->state = self::STATE_INIT; } + public function setMemoryLimit(int $limit): void + { + $this->memoryLimit = $limit; + } + /** * Resets the StatelessApplication state and prepares the Yii2 environment for handling a PSR-7 request. * diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 0679c04f..e7912fef 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -617,4 +617,29 @@ static function () use (&$beforeRequestTriggered): void { "Should trigger 'EVENT_BEFORE_REQUEST' event during 'handle()' execution in 'StatelessApplication'.", ); } + + public function testGetMemoryLimitRecalculatesWhenMemoryLimitIsZero(): void + { + $originalLimit = ini_get('memory_limit'); + + try { + ini_set('memory_limit', '256M'); + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + $app = $this->statelessApplication(); + + $app->handle($request); + $app->setMemoryLimit(0); + + ini_set('memory_limit', '128M'); + + self::assertSame( + true, + $app->clean(), + "Should recalculate 'memory_limit' when current 'memoryLimit' is exactly '0'.", + ); + } finally { + ini_set('memory_limit', $originalLimit); + } + } } From 39466d4f5df4bc70e0eaf10ac70de038855492f8 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Mon, 28 Jul 2025 13:59:24 +0000 Subject: [PATCH 37/73] Apply fixes from StyleCI --- tests/http/StatelessApplicationTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index e7912fef..96fecc06 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -71,7 +71,6 @@ public function testGetMemoryLimitParsesMemoryLimitWithSuffix(): void $cleanResult, "Should return boolean from 'clean()' when 'memory_limit' '64M' is properly parsed to bytes.", ); - } finally { ini_set('memory_limit', $originalLimit); } From bd137c62dde0df9ab69996a5722b73842229eabb Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 10:02:09 -0400 Subject: [PATCH 38/73] test: Update `testGetMemoryLimitRecalculatesWhenMemoryLimitIsZero()` to remove assertions and ensure proper `memory_limit` handling. --- tests/http/StatelessApplicationTest.php | 48 ++++++++++++------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 96fecc06..cacc6d9a 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -76,6 +76,29 @@ public function testGetMemoryLimitParsesMemoryLimitWithSuffix(): void } } + public function testGetMemoryLimitRecalculatesWhenMemoryLimitIsZero(): void + { + $originalLimit = ini_get('memory_limit'); + + try { + ini_set('memory_limit', '256M'); + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + $app = $this->statelessApplication(); + + $app->handle($request); + $app->setMemoryLimit(0); + + ini_set('memory_limit', '128M'); + + $this->expectNotToPerformAssertions(); + + $app->clean(); + } finally { + ini_set('memory_limit', $originalLimit); + } + } + public function testReturnCookiesHeadersForSiteCookieRoute(): void { $_SERVER = [ @@ -616,29 +639,4 @@ static function () use (&$beforeRequestTriggered): void { "Should trigger 'EVENT_BEFORE_REQUEST' event during 'handle()' execution in 'StatelessApplication'.", ); } - - public function testGetMemoryLimitRecalculatesWhenMemoryLimitIsZero(): void - { - $originalLimit = ini_get('memory_limit'); - - try { - ini_set('memory_limit', '256M'); - - $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); - $app = $this->statelessApplication(); - - $app->handle($request); - $app->setMemoryLimit(0); - - ini_set('memory_limit', '128M'); - - self::assertSame( - true, - $app->clean(), - "Should recalculate 'memory_limit' when current 'memoryLimit' is exactly '0'.", - ); - } finally { - ini_set('memory_limit', $originalLimit); - } - } } From e900d514c2cf771756992a5ed9f3a20ac353e138 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 10:06:10 -0400 Subject: [PATCH 39/73] test: Update `testGetMemoryLimitHandlesUnlimitedMemoryCorrectly()` to expect no assertions and improve `memory_limit` handling. --- tests/http/StatelessApplicationTest.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index cacc6d9a..db015be9 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -40,14 +40,11 @@ public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $app = $this->statelessApplication(); - $app->handle($request); - self::assertFalse( - $app->clean(), - "Should return 'false' from 'clean()' when 'memory_limit' is unlimited ('-1'), indicating memory usage " . - 'is below threshold.', - ); + $this->expectNotToPerformAssertions(); + + $app->clean(); } finally { ini_set('memory_limit', $originalLimit); } @@ -84,6 +81,7 @@ public function testGetMemoryLimitRecalculatesWhenMemoryLimitIsZero(): void ini_set('memory_limit', '256M'); $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + $app = $this->statelessApplication(); $app->handle($request); From 69b7f62cc9e681bb86077680b8ea09ad44367108 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 10:08:08 -0400 Subject: [PATCH 40/73] test: Update `testGetMemoryLimitHandlesUnlimitedMemoryCorrectly()` to expect no assertions and simplify memory handling. --- tests/http/StatelessApplicationTest.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index db015be9..fe4cfe93 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -61,13 +61,10 @@ public function testGetMemoryLimitParsesMemoryLimitWithSuffix(): void $app = $this->statelessApplication(); $app->handle($request); - $cleanResult = $app->clean(); - self::assertSame( - false, - $cleanResult, - "Should return boolean from 'clean()' when 'memory_limit' '64M' is properly parsed to bytes.", - ); + $this->expectNotToPerformAssertions(); + + $app->clean(); } finally { ini_set('memory_limit', $originalLimit); } From 74f8616275e216f9849f0e33e7cbe185b6fd063c Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 10:31:34 -0400 Subject: [PATCH 41/73] refactor: Rename `memory_limit` methods for clarity and update tests to validate `memory_limit` handling. --- src/http/StatelessApplication.php | 57 ++++++++++++++----------- tests/http/StatelessApplicationTest.php | 8 +++- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 9a73d5ae..8ad670cc 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -94,7 +94,7 @@ public function __construct(array $config = []) { $this->config = $config; - $this->memoryLimit = $this->getMemoryLimit(); + $this->memoryLimit = $this->handleMemoryLimit(); $this->initEventTracking(); } @@ -165,6 +165,11 @@ public function coreComponents(): array ); } + public function getMemoryLimit(): int|null + { + return $this->memoryLimit; + } + /** * Handles a PSR-7 ServerRequestInterface and returns a PSR-7 ResponseInterface. * @@ -347,6 +352,30 @@ private function cleanupEvents(): void Event::offAll(); } + /** + * Handles application errors and returns a Yii2 Response instance. + * + * Invokes the configured error handler to process the exception and generate a response, then triggers the + * {@see self::EVENT_AFTER_REQUEST} event and sets the application state to {@see self::STATE_END}. + * + * This method ensures that all errors are handled consistently and the application lifecycle is finalized after + * an exception occurs. + * + * @param Throwable $exception Exception instance to handle. + * + * @return Response Response instance generated by the error handler. + */ + private function handleError(Throwable $exception): Response + { + $response = $this->errorHandler->handleException($exception); + + $this->trigger(self::EVENT_AFTER_REQUEST); + + $this->state = self::STATE_END; + + return $response; + } + /** * Retrieves and parses the configured PHP memory limit for the application. * @@ -360,7 +389,7 @@ private function cleanupEvents(): void * * @return int Memory limit in bytes as configured in PHP, or 'PHP_INT_MAX' if unlimited. */ - private function getMemoryLimit(): int + private function handleMemoryLimit(): int { if ($this->memoryLimit === null || $this->memoryLimit <= 0) { $limit = ini_get('memory_limit'); @@ -387,30 +416,6 @@ private function getMemoryLimit(): int return $this->memoryLimit; } - /** - * Handles application errors and returns a Yii2 Response instance. - * - * Invokes the configured error handler to process the exception and generate a response, then triggers the - * {@see self::EVENT_AFTER_REQUEST} event and sets the application state to {@see self::STATE_END}. - * - * This method ensures that all errors are handled consistently and the application lifecycle is finalized after - * an exception occurs. - * - * @param Throwable $exception Exception instance to handle. - * - * @return Response Response instance generated by the error handler. - */ - private function handleError(Throwable $exception): Response - { - $response = $this->errorHandler->handleException($exception); - - $this->trigger(self::EVENT_AFTER_REQUEST); - - $this->state = self::STATE_END; - - return $response; - } - /** * Initializes the event tracking handler for the application lifecycle. * diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index fe4cfe93..9c73558f 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -40,10 +40,14 @@ public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $app = $this->statelessApplication(); - $app->handle($request); - $this->expectNotToPerformAssertions(); + self::assertSame( + PHP_INT_MAX, + $app->getMemoryLimit(), + "Memory limit should be 'PHP_INT_MAX' when set to '-1' (unlimited) in 'StatelessApplication'.", + ); + $app->handle($request); $app->clean(); } finally { ini_set('memory_limit', $originalLimit); From ee68a326d5844bee42d62a58a92254fca5d191aa Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 18:21:43 -0400 Subject: [PATCH 42/73] feat: Enhance memory management in `StatelessApplication` class with dynamic recalculation and new methods. --- src/http/StatelessApplication.php | 156 ++++++++++++++++++++---------- 1 file changed, 107 insertions(+), 49 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 8ad670cc..f5bb022b 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -83,6 +83,11 @@ final class StatelessApplication extends Application implements RequestHandlerIn */ private array $registeredEvents = []; + /** + * Flag to indicate if memory limit should be recalculated. + */ + private bool $shouldRecalculateMemoryLimit = false; + /** * Creates a new instance of the {@see StatelessApplication} class. * @@ -94,8 +99,6 @@ public function __construct(array $config = []) { $this->config = $config; - $this->memoryLimit = $this->handleMemoryLimit(); - $this->initEventTracking(); } @@ -121,8 +124,9 @@ public function clean(): bool { gc_collect_cycles(); - $limit = (int) $this->memoryLimit; - $bound = $limit * .90; + $limit = $this->getMemoryLimit(); + + $bound = (int) ($limit * 0.9); $usage = memory_get_usage(true); @@ -130,7 +134,7 @@ public function clean(): bool } /** - * Returns the core components configuration for the {@see StatelessApplication}. + * Returns the core components configuration for the StatelessApplication. * * Provides the array of core Yii2 components required for stateless operation, including error handler, request, * response, session, and user components. @@ -165,8 +169,30 @@ public function coreComponents(): array ); } - public function getMemoryLimit(): int|null + /** + * Returns the memory limit for the StatelessApplication in bytes. + * + * If the memory limit is not set or recalculation is required, this method retrieves the system memory limit + * using {@see getSystemMemoryLimit()}, parses it to an integer value in bytes, and stores it for future access. + * + * This ensures that the application always operates with an up-to-date memory limit, supporting dynamic + * recalculation when needed. + * + * @return int Memory limit in bytes for the StatelessApplication. + * + * Usage example: + * ```php + * $limit = $app->getMemoryLimit(); + * ``` + */ + public function getMemoryLimit(): int { + if ($this->memoryLimit === null || $this->shouldRecalculateMemoryLimit) { + $this->memoryLimit = self::parseMemoryLimit($this->getSystemMemoryLimit()); + + $this->shouldRecalculateMemoryLimit = false; + } + return $this->memoryLimit; } @@ -237,9 +263,73 @@ public function init(): void $this->state = self::STATE_INIT; } + /** + * Sets the memory limit for the StatelessApplication. + * + * - If the provided limit is less than or equal to zero, the memory limit will be recalculated from the system + * configuration on the next access. + * - Otherwise, sets the memory limit to the specified value in bytes and disables recalculation. + * + * @param int $limit Memory limit in bytes. Use a value less than or equal to zero to trigger recalculation from + * system settings. + */ public function setMemoryLimit(int $limit): void { - $this->memoryLimit = $limit; + if ($limit <= 0) { + $this->shouldRecalculateMemoryLimit = true; + $this->memoryLimit = null; + } else { + $this->memoryLimit = $limit; + $this->shouldRecalculateMemoryLimit = false; + } + } + + /** + * Retrieves the current memory limit from the PHP configuration. + * + * @return string Memory limit value from ini_get('memory_limit'). + */ + protected function getSystemMemoryLimit(): string + { + return ini_get('memory_limit'); + } + + /** + * Parses a PHP memory limit string and converts it to bytes. + * + * Supports the following formats. + * - '-1' for unlimited (returns 'PHP_INT_MAX'). + * - Numeric values with suffix: K (kilobytes), M (megabytes), G (gigabytes). + * - Plain numeric values (bytes). + * + * @param string $limit Memory limit string to parse. + * + * @return int Memory limit in bytes, or 'PHP_INT_MAX' if unlimited. + */ + protected static function parseMemoryLimit(string $limit): int + { + if ($limit === '-1') { + return PHP_INT_MAX; + } + + $number = 0; + $suffix = null; + + sscanf($limit, '%u%c', $number, $suffix); + + if ($suffix !== null) { + $multipliers = [ + ' ' => 1, + 'K' => 1024, + 'M' => 1048576, + 'G' => 1073741824, + ]; + + $suffix = strtoupper((string) $suffix); + $number = (int) $number * ($multipliers[$suffix] ?? 1); + } + + return (int) $number; } /** @@ -266,6 +356,13 @@ protected function reset(ServerRequestInterface $request): void $this->startEventTracking(); + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + + // clear the global session array to prevent data leakage + $_SESSION = []; + $config = $this->config; if ($this->has('errorHandler')) { @@ -282,12 +379,13 @@ protected function reset(ServerRequestInterface $request): void $this->request->setPsr7Request($request); $this->session->close(); - $sessionId = $request->getCookieParams()[$this->session->getName()] ?? null; + $sessionId = $this->request->getCookies()->get($this->session->getName())->value ?? null; - if (is_string($sessionId)) { + if (is_string($sessionId) && $sessionId !== '') { $this->session->setId($sessionId); } + // start the session with the correct 'ID' $this->session->open(); $this->bootstrap(); @@ -376,46 +474,6 @@ private function handleError(Throwable $exception): Response return $response; } - /** - * Retrieves and parses the configured PHP memory limit for the application. - * - * Determines the memory limit by reading the 'memory_limit' value from the PHP configuration, parsing the value and - * converting it to an integer representing the number of bytes. - * - * Supports suffixes for kilobytes (K), megabytes (M), and gigabytes (G), and returns 'PHP_INT_MAX' if unlimited. - * - * This method is used to set the internal memory limit for the application, enabling memory usage checks and - * recycling logic in worker and SAPI environments. - * - * @return int Memory limit in bytes as configured in PHP, or 'PHP_INT_MAX' if unlimited. - */ - private function handleMemoryLimit(): int - { - if ($this->memoryLimit === null || $this->memoryLimit <= 0) { - $limit = ini_get('memory_limit'); - - if ($limit === '-1') { - $this->memoryLimit = PHP_INT_MAX; - - return $this->memoryLimit; - } - - sscanf($limit, '%u%c', $number, $suffix); - - if (isset($suffix)) { - $multipliers = [' ' => 1, 'K' => 1024, 'M' => 1048576, 'G' => 1073741824]; - - $suffix = strtoupper((string) $suffix); - - $number = (int) $number * ($multipliers[$suffix] ?? 1); - } - - $this->memoryLimit = (int) $number; - } - - return $this->memoryLimit; - } - /** * Initializes the event tracking handler for the application lifecycle. * From d33d44e946676e70ead3f7c9953e2a9c1fc03d62 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 18:25:06 -0400 Subject: [PATCH 43/73] feat: Add additional symbols to the whitelist in `composer-require-checker.json`. --- composer-require-checker.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer-require-checker.json b/composer-require-checker.json index 50a54a46..77caf405 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -1,5 +1,8 @@ { "symbol-whitelist": [ + "PHP_SESSION_ACTIVE", + "session_status", + "session_write_close", "uopz_redefine", "YII_DEBUG" ] From 608ef66b16f74016f989d89ced9336f9f35bb7bf Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 18:43:36 -0400 Subject: [PATCH 44/73] refactor: Simplify test structure and add event data provider for `StatelessApplication` class tests. --- tests/http/StatelessApplicationTest.php | 133 ++++-------------- .../provider/StatelessApplicationProvider.php | 21 +++ 2 files changed, 51 insertions(+), 103 deletions(-) create mode 100644 tests/provider/StatelessApplicationProvider.php diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 9c73558f..d6f352bf 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -4,19 +4,15 @@ namespace yii2\extensions\psrbridge\tests\http; -use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; use Psr\Http\Message\ResponseInterface; use Yii; use yii\base\Security; -use yii\i18n\Formatter; -use yii\i18n\I18N; +use yii\i18n\{Formatter, I18N}; use yii\log\Dispatcher; -use yii\web\AssetManager; -use yii\web\Session; -use yii\web\UrlManager; -use yii\web\User; -use yii\web\View; +use yii\web\{AssetManager, Session, UrlManager, User, View}; use yii2\extensions\psrbridge\http\{ErrorHandler, Request, Response}; +use yii2\extensions\psrbridge\tests\provider\StatelessApplicationProvider; use yii2\extensions\psrbridge\tests\support\FactoryHelper; use yii2\extensions\psrbridge\tests\TestCase; @@ -54,50 +50,6 @@ public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void } } - public function testGetMemoryLimitParsesMemoryLimitWithSuffix(): void - { - $originalLimit = ini_get('memory_limit'); - - try { - ini_set('memory_limit', '64M'); - - $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); - - $app = $this->statelessApplication(); - $app->handle($request); - - $this->expectNotToPerformAssertions(); - - $app->clean(); - } finally { - ini_set('memory_limit', $originalLimit); - } - } - - public function testGetMemoryLimitRecalculatesWhenMemoryLimitIsZero(): void - { - $originalLimit = ini_get('memory_limit'); - - try { - ini_set('memory_limit', '256M'); - - $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); - - $app = $this->statelessApplication(); - - $app->handle($request); - $app->setMemoryLimit(0); - - ini_set('memory_limit', '128M'); - - $this->expectNotToPerformAssertions(); - - $app->clean(); - } finally { - ini_set('memory_limit', $originalLimit); - } - } - public function testReturnCookiesHeadersForSiteCookieRoute(): void { $_SERVER = [ @@ -124,28 +76,26 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void $cookies = $response->getHeaders()['set-cookie'] ?? []; foreach ($cookies as $i => $cookie) { - // Skip the last cookie header (assumed to be 'PHPSESSION'). - if ((int) $i + 1 === count($cookies)) { - continue; + // skip the last cookie header (assumed to be 'PHPSESSION'). + if (str_starts_with($cookie, 'PHPSESSID=') === false) { + $params = explode('; ', $cookie); + + self::assertTrue( + in_array( + $params[0], + [ + 'test=test', + 'test2=test2', + ], + true, + ), + sprintf( + "Cookie header should contain either 'test=test' or 'test2=test2', got '%s' for 'site/cookie' " . + 'route.', + $params[0], + ), + ); } - - $params = explode('; ', $cookie); - - self::assertTrue( - in_array( - $params[0], - [ - 'test=test', - 'test2=test2', - ], - true, - ), - sprintf( - "Cookie header should contain either 'test=test' or 'test2=test2', got '%s' for 'site/cookie' " . - 'route.', - $params[0], - ), - ); } } @@ -595,47 +545,24 @@ public function testSetWebAndWebrootAliasesAfterHandleRequest(): void ); } - public function testTriggerAfterRequestEventDuringHandle(): void + #[DataProviderExternal(StatelessApplicationProvider::class, 'eventDataProvider')] + public function testTriggerEventDuringHandle(string $eventName): void { - $afterRequestTriggered = false; + $eventTriggered = false; $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); - $app = $this->statelessApplication(); - $app->on( - $app::EVENT_AFTER_REQUEST, - static function () use (&$afterRequestTriggered): void { - $afterRequestTriggered = true; - }, - ); - - $app->handle($request); - - self::assertTrue( - $afterRequestTriggered, - "Should trigger 'EVENT_AFTER_REQUEST' event during 'handle()' execution in 'StatelessApplication'.", - ); - } - - public function testTriggerBeforeRequestEventDuringHandle(): void - { - $beforeRequestTriggered = false; - - $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $app = $this->statelessApplication(); $app->on( - $app::EVENT_BEFORE_REQUEST, - static function () use (&$beforeRequestTriggered): void { - $beforeRequestTriggered = true; + $eventName, + static function () use (&$eventTriggered): void { + $eventTriggered = true; }, ); $app->handle($request); - self::assertTrue( - $beforeRequestTriggered, - "Should trigger 'EVENT_BEFORE_REQUEST' event during 'handle()' execution in 'StatelessApplication'.", - ); + self::assertTrue($eventTriggered, "Should trigger '{$eventName}' event during handle()"); } } diff --git a/tests/provider/StatelessApplicationProvider.php b/tests/provider/StatelessApplicationProvider.php new file mode 100644 index 00000000..39194a58 --- /dev/null +++ b/tests/provider/StatelessApplicationProvider.php @@ -0,0 +1,21 @@ + + */ + public static function eventDataProvider(): array + { + return [ + 'after request' => [StatelessApplication::EVENT_AFTER_REQUEST], + 'before request' => [StatelessApplication::EVENT_BEFORE_REQUEST], + ]; + } +} From 9482b36d72118a4ef676fc8aa49cb2dfacdef81c Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 18:50:27 -0400 Subject: [PATCH 45/73] refactor: Simplify memory limit test by removing try-finally and unnecessary variable usage. --- tests/http/StatelessApplicationTest.php | 30 ++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index d6f352bf..76165b9c 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -30,24 +30,22 @@ public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void { $originalLimit = ini_get('memory_limit'); - try { - ini_set('memory_limit', '-1'); + ini_set('memory_limit', '-1'); - $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); - $app = $this->statelessApplication(); + $app = $this->statelessApplication(); - self::assertSame( - PHP_INT_MAX, - $app->getMemoryLimit(), - "Memory limit should be 'PHP_INT_MAX' when set to '-1' (unlimited) in 'StatelessApplication'.", - ); + self::assertSame( + PHP_INT_MAX, + $app->getMemoryLimit(), + "Memory limit should be 'PHP_INT_MAX' when set to '-1' (unlimited) in 'StatelessApplication'.", + ); - $app->handle($request); - $app->clean(); - } finally { - ini_set('memory_limit', $originalLimit); - } + $app->handle($request); + $app->clean(); + + ini_set('memory_limit', $originalLimit); } public function testReturnCookiesHeadersForSiteCookieRoute(): void @@ -75,7 +73,7 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void $cookies = $response->getHeaders()['set-cookie'] ?? []; - foreach ($cookies as $i => $cookie) { + foreach ($cookies as $cookie) { // skip the last cookie header (assumed to be 'PHPSESSION'). if (str_starts_with($cookie, 'PHPSESSID=') === false) { $params = explode('; ', $cookie); @@ -104,6 +102,7 @@ public function testReturnCoreComponentsConfigurationAfterHandle(): void $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $app = $this->statelessApplication(); + $app->handle($request); self::assertSame( @@ -530,6 +529,7 @@ public function testSetWebAndWebrootAliasesAfterHandleRequest(): void $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $app = $this->statelessApplication(); + $app->handle($request); self::assertSame( From 50d3e9092f57f49063c1cda6110ec588f27a9b82 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 19:08:39 -0400 Subject: [PATCH 46/73] feat: Add tests for memory limit recalculation and handling in `StatelessApplication` class. --- tests/http/StatelessApplicationTest.php | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 76165b9c..024f7407 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -48,6 +48,37 @@ public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void ini_set('memory_limit', $originalLimit); } + public function testRecalculateMemoryLimitAfterResetAndIniChange(): void + { + $originalLimit = ini_get('memory_limit'); + + ini_set('memory_limit', '256M'); + + $app = $this->statelessApplication(); + + $firstCalculation = $app->getMemoryLimit(); + $app->setMemoryLimit(0); + + ini_set('memory_limit', '128M'); + + $secondCalculation = $app->getMemoryLimit(); + + self::assertSame( + 134_217_728, + $secondCalculation, + "'getMemoryLimit()' should return '134_217_728' ('128M') after resetting and updating 'memory_limit' to " . + "'128M' in 'StatelessApplication'.", + ); + self::assertNotSame( + $firstCalculation, + $secondCalculation, + "'getMemoryLimit()' should return a different value after recalculation when 'memory_limit' changes in " . + "'StatelessApplication'.", + ); + + ini_set('memory_limit', $originalLimit); + } + public function testReturnCookiesHeadersForSiteCookieRoute(): void { $_SERVER = [ @@ -150,6 +181,27 @@ public function testReturnCoreComponentsConfigurationAfterHandle(): void ); } + public function testReturnFalseFromCleanWhenMemoryUsageIsBelowThreshold(): void + { + $originalLimit = ini_get('memory_limit'); + + ini_set('memory_limit', '1G'); + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + + $app->handle($request); + + self::assertFalse( + $app->clean(), + "'clean()' should return 'false' when memory usage is below '90%' of the configured 'memory_limit' in " . + "'StatelessApplication'.", + ); + + ini_set('memory_limit', $originalLimit); + } + public function testReturnJsonResponseWithCookiesForSiteGetCookiesRoute(): void { $_COOKIE = [ @@ -328,6 +380,34 @@ public function testReturnJsonResponseWithQueryParametersForSiteGetRoute(): void ); } + public function testReturnPhpIntMaxWhenMemoryLimitIsUnlimited(): void + { + $originalLimit = ini_get('memory_limit'); + + ini_set('memory_limit', '-1'); + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + + $app->handle($request); + + self::assertSame( + PHP_INT_MAX, + $app->getMemoryLimit(), + "'getMemoryLimit()' should return 'PHP_INT_MAX' when 'memory_limit' is set to '-1' (unlimited) in " . + "'StatelessApplication'.", + ); + self::assertSame( + PHP_INT_MAX, + $app->getMemoryLimit(), + "'getMemoryLimit()' should remain 'PHP_INT_MAX' after handling a request with unlimited memory in " . + "'StatelessApplication'.", + ); + + ini_set('memory_limit', $originalLimit); + } + public function testReturnPlainTextFileResponseForSiteFileRoute(): void { $_SERVER = [ From 5074da5342cdbe677204ca67dd117c364dba1be9 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 19:19:02 -0400 Subject: [PATCH 47/73] fix: Reset session `ID` to ensure a new session is created when `session_id` is invalid. --- src/http/StatelessApplication.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index f5bb022b..2b14efb1 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -383,6 +383,9 @@ protected function reset(ServerRequestInterface $request): void if (is_string($sessionId) && $sessionId !== '') { $this->session->setId($sessionId); + } else { + // reset session 'ID' to ensure a new session is created + $this->session->setId(''); } // start the session with the correct 'ID' From a2251c728ac2f0e27ffa8724a42db2e7466b54c8 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 21:42:02 -0400 Subject: [PATCH 48/73] feat: Implement session handling tests to ensure isolation between requests in `StatelessApplication` class. --- src/http/StatelessApplication.php | 5 +- tests/http/StatelessApplicationTest.php | 82 +++++++++++++++++++++++++ tests/support/stub/SiteController.php | 20 ++++++ 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 2b14efb1..a50e81df 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -381,11 +381,8 @@ protected function reset(ServerRequestInterface $request): void $this->session->close(); $sessionId = $this->request->getCookies()->get($this->session->getName())->value ?? null; - if (is_string($sessionId) && $sessionId !== '') { + if (is_string($sessionId)) { $this->session->setId($sessionId); - } else { - // reset session 'ID' to ensure a new session is created - $this->session->setId(''); } // start the session with the correct 'ID' diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 024f7407..d4b96841 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -8,6 +8,7 @@ use Psr\Http\Message\ResponseInterface; use Yii; use yii\base\Security; +use yii\helpers\Json; use yii\i18n\{Formatter, I18N}; use yii\log\Dispatcher; use yii\web\{AssetManager, Session, UrlManager, User, View}; @@ -604,6 +605,87 @@ public function testReturnsStatusCode201ForSiteStatuscodeRoute(): void ); } + public function testSessionIsolationBetweenRequests(): void + { + $_COOKIE = ['PHPSESSID' => 'session-user-a']; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/setsession', + ]; + + $request1 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + + $response1 = $app->handle($request1); + + self::assertSame( + 200, + $response1->getStatusCode(), + "Response status code should be '200' for 'site/setsession' route in 'StatelessApplication'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response1->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/setsession' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 'PHPSESSID=session-user-a; Path=/; HttpOnly; SameSite', + $response1->getHeaders()['Set-Cookie'][0] ?? '', + "Response 'set-cookie' header should contain 'session-user-a' for 'site/setsession' route in " . + "'StatelessApplication'.", + ); + + $_COOKIE = ['PHPSESSID' => 'session-user-b']; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/getsession', + ]; + + $request2 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response2 = $app->handle($request2); + + self::assertSame( + 200, + $response2->getStatusCode(), + "Response status code should be '200' for 'site/getsession' route in 'StatelessApplication'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response2->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/getsession' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 'PHPSESSID=session-user-b; Path=/; HttpOnly; SameSite', + $response2->getHeaders()['Set-Cookie'][0] ?? '', + "Response 'set-cookie' header should contain 'session-user-b' for 'site/getsession' route in " . + "'StatelessApplication'.", + ); + + $body = Json::decode($response2->getBody()->getContents(), true); + + self::assertIsArray( + $body, + "Response body should be an array after decoding JSON response from 'site/getsession' route in " . + "'StatelessApplication'.", + ); + + $testValue = ''; + + if (array_key_exists('testValue', $body)) { + $testValue = $body['testValue']; + } + + self::assertNull( + $testValue, + "Session data from first request should not leak to second request with different session 'ID' in " . + "'StatelessApplication'.", + ); + } + public function testSetWebAndWebrootAliasesAfterHandleRequest(): void { $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index cac8e669..2a9aa260 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -4,6 +4,7 @@ namespace yii2\extensions\psrbridge\tests\support\stub; +use Yii; use yii\base\Exception; use yii\web\{Controller, Cookie, CookieCollection, Response}; @@ -76,6 +77,16 @@ public function actionGetcookies(): CookieCollection return $this->request->getCookies(); } + /** + * @phpstan-return array + */ + public function actionGetsession(): array + { + $this->response->format = Response::FORMAT_JSON; + + return ['testValue' => Yii::$app->session->get('testValue')]; + } + /** * @phpstan-return string[] */ @@ -103,6 +114,15 @@ public function actionRefresh(): void $this->response->refresh('#stateless'); } + public function actionSetsession(): void + { + $this->response->format = Response::FORMAT_JSON; + + Yii::$app->session->set('testValue', 'test-value'); + + $this->response->data = ['status' => 'ok']; + } + public function actionStatuscode(): void { $this->response->statusCode = 201; From 97e1853081fb8c68517feb087039a18c9d945e52 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Mon, 28 Jul 2025 21:47:23 -0400 Subject: [PATCH 49/73] feat: Add session configuration to test case for improved session handling. --- tests/TestCase.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index 467cc351..d9d13e94 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -137,6 +137,9 @@ protected function statelessApplication($config = []): StatelessApplication 'response' => [ 'charset' => 'UTF-8', ], + 'session' => [ + 'name' => 'PHPSESSID', + ], 'user' => [ 'enableAutoLogin' => false, ], From 9c1c0f49ed9370950ef1c2117581a6cf1ac86168 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 06:31:08 -0400 Subject: [PATCH 50/73] feat: Implement user authentication and session handling in `SiteController` with session isolation tests. --- tests/TestCase.php | 2 + tests/http/StatelessApplicationTest.php | 322 +++++++++++++++++++++++- tests/phpstan-config.php | 4 + tests/support/stub/Identity.php | 91 +++++++ tests/support/stub/SiteController.php | 53 ++++ 5 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 tests/support/stub/Identity.php diff --git a/tests/TestCase.php b/tests/TestCase.php index d9d13e94..137b1e9f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -13,6 +13,7 @@ use yii\log\FileTarget; use yii\web\JsonParser; use yii2\extensions\psrbridge\http\StatelessApplication; +use yii2\extensions\psrbridge\tests\support\stub\Identity; use function fclose; use function tmpfile; @@ -142,6 +143,7 @@ protected function statelessApplication($config = []): StatelessApplication ], 'user' => [ 'enableAutoLogin' => false, + 'identityClass' => Identity::class, ], 'urlManager' => [ 'showScriptName' => false, diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index d4b96841..74ed70a5 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -27,6 +27,153 @@ protected function tearDown(): void parent::tearDown(); } + public function testCaptchaSessionIsolation(): void + { + // first user generates captcha - need to use refresh=1 to get JSON response + $_COOKIE = ['PHPSESSID' => 'user-a-session']; + $_GET = ['refresh' => '1']; + $_SERVER = [ + 'QUERY_STRING' => 'refresh=1', + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/captcha', + ]; + + $request1 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + + $response1 = $app->handle($request1); + + self::assertSame( + 'application/json; charset=UTF-8', + $response1->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/captcha' route in " . + "'StatelessApplication'.", + ); + + $captchaData1 = Json::decode($response1->getBody()->getContents(), true); + + self::assertIsArray( + $captchaData1, + "Captcha response should be an array after decoding JSON for 'site/captcha' route in " . + "'StatelessApplication'.", + ); + self::assertArrayHasKey( + 'hash1', + $captchaData1, + "Captcha response should contain 'hash1' key for 'site/captcha' route in 'StatelessApplication'.", + ); + self::assertArrayHasKey( + 'hash2', + $captchaData1, + "Captcha response should contain 'hash2' key for 'site/captcha' route in 'StatelessApplication'.", + ); + self::assertArrayHasKey( + 'url', + $captchaData1, + "Captcha response should contain 'url' key for 'site/captcha' route in 'StatelessApplication'.", + ); + + // second user requests captcha - should get different data + $_COOKIE = ['PHPSESSID' => 'user-b-session']; + $_GET = ['refresh' => '1']; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/captcha', + 'QUERY_STRING' => 'refresh=1', + ]; + + $request2 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response2 = $app->handle($request2); + + $captchaData2 = Json::decode($response2->getBody()->getContents(), true); + + self::assertIsArray( + $captchaData2, + "Captcha response should be an array after decoding JSON for second user in 'site/captcha' route in " . + "'StatelessApplication'.", + ); + self::assertArrayHasKey( + 'hash1', + $captchaData2, + "Captcha response should contain 'hash1' key for second user in 'site/captcha' route in " . + "'StatelessApplication'.", + ); + + $hash1 = $captchaData1['hash1'] ?? null; + $hash2 = $captchaData2['hash2'] ?? null; + + self::assertNotNull( + $hash1, + "First captcha response 'hash1' should not be 'null' for 'site/captcha' route in 'StatelessApplication'.", + ); + self::assertNotNull( + $hash2, + "Second captcha response 'hash2' should not be 'null' for 'site/captcha' route in 'StatelessApplication'.", + ); + self::assertNotSame( + $hash1, + $hash2, + "Captcha 'hash1' for first user should not match 'hash2' for second user, ensuring session isolation in " . + "'StatelessApplication'.", + ); + + // also test that we can get the actual captcha image + $url = $captchaData2['url'] ?? null; + + self::assertNotNull( + $url, + "Captcha response 'url' should not be 'null' for second user in 'site/captcha' route in " . + "'StatelessApplication'.", + ); + + $_COOKIE = ['PHPSESSID' => 'user-a-session']; + $_GET = []; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => $url, + ]; + + $request3 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response3 = $app->handle($request3); + + self::assertIsString( + $url, + "Captcha response 'url' should be a string for second user in 'site/captcha' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 'image/png', + $response3->getHeaders()['content-type'][0] ?? '', + "Captcha image response 'content-type' should be 'image/png' for '{$url}' in 'StatelessApplication'.", + ); + + $imageContent = $response3->getBody()->getContents(); + + self::assertNotEmpty( + $imageContent, + "Captcha image content should not be empty for '{$url}' in 'StatelessApplication'.", + ); + self::assertSame( + 'image/png', + $response3->getHeaders()['content-type'][0], + "Captcha image response 'content-type' should be 'image/png' for '{$url}' in 'StatelessApplication'.", + ); + self::assertSame( + 'PHPSESSID=user-a-session; Path=/; HttpOnly; SameSite', + $response3->getHeaders()['Set-Cookie'][0] ?? '', + "Captcha image response 'Set-Cookie' should contain 'user-a-session' for '{$url}' in " . + "'StatelessApplication'.", + ); + self::assertStringStartsWith( + "\x89PNG", + $imageContent, + "Captcha image content should start with PNG header for '{$url}' in 'StatelessApplication'.", + ); + } + public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void { $originalLimit = ini_get('memory_limit'); @@ -605,6 +752,91 @@ public function testReturnsStatusCode201ForSiteStatuscodeRoute(): void ); } + public function testSessionDataPersistenceWithSameSessionId(): void + { + $sessionId = 'test-session-' . uniqid(); + + $_COOKIE = ['PHPSESSID' => $sessionId]; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/setsession', + ]; + + // first request - set session data + $request1 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + + $response1 = $app->handle($request1); + + self::assertSame( + 200, + $response1->getStatusCode(), + "Response status code should be '200' for 'site/setsession' route in 'StatelessApplication'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response1->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/setsession' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + "PHPSESSID={$sessionId}; Path=/; HttpOnly; SameSite", + $response1->getHeaders()['Set-Cookie'][0] ?? '', + "Response 'Set-Cookie' header should contain '{$sessionId}' for 'site/setsession' route in " . + "'StatelessApplication'.", + ); + + $_COOKIE = ['PHPSESSID' => $sessionId]; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/getsession', + ]; + + // second request - same session ID should retrieve the data + $request2 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response2 = $app->handle($request2); + + self::assertSame( + 200, + $response2->getStatusCode(), + "Response status code should be '200' for 'site/getsession' route in 'StatelessApplication'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response2->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/getsession' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + "PHPSESSID={$sessionId}; Path=/; HttpOnly; SameSite", + $response2->getHeaders()['Set-Cookie'][0] ?? '', + "Response 'Set-Cookie' header should contain '{$sessionId}' for 'site/getsession' route in " . + "'StatelessApplication'.", + ); + + $body = Json::decode($response2->getBody()->getContents(), true); + + self::assertIsArray( + $body, + "Response body should be an array after decoding JSON response from 'site/getsession' route in " . + "'StatelessApplication'.", + ); + + $testValue = ''; + + if (array_key_exists('testValue', $body)) { + $testValue = $body['testValue']; + } + + self::assertSame( + 'test-value', + $testValue, + 'Session data should persist between requests with the same session ID', + ); + } + public function testSessionIsolationBetweenRequests(): void { $_COOKIE = ['PHPSESSID' => 'session-user-a']; @@ -613,6 +845,7 @@ public function testSessionIsolationBetweenRequests(): void 'REQUEST_URI' => 'site/setsession', ]; + // first request - set a session value $request1 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $app = $this->statelessApplication(); @@ -633,7 +866,7 @@ public function testSessionIsolationBetweenRequests(): void self::assertSame( 'PHPSESSID=session-user-a; Path=/; HttpOnly; SameSite', $response1->getHeaders()['Set-Cookie'][0] ?? '', - "Response 'set-cookie' header should contain 'session-user-a' for 'site/setsession' route in " . + "Response 'Set-Cookie' header should contain 'session-user-a' for 'site/setsession' route in " . "'StatelessApplication'.", ); @@ -643,6 +876,7 @@ public function testSessionIsolationBetweenRequests(): void 'REQUEST_URI' => 'site/getsession', ]; + // second request - different session $request2 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $response2 = $app->handle($request2); @@ -661,7 +895,7 @@ public function testSessionIsolationBetweenRequests(): void self::assertSame( 'PHPSESSID=session-user-b; Path=/; HttpOnly; SameSite', $response2->getHeaders()['Set-Cookie'][0] ?? '', - "Response 'set-cookie' header should contain 'session-user-b' for 'site/getsession' route in " . + "Response 'Set-Cookie' header should contain 'session-user-b' for 'site/getsession' route in " . "'StatelessApplication'.", ); @@ -727,4 +961,88 @@ static function () use (&$eventTriggered): void { self::assertTrue($eventTriggered, "Should trigger '{$eventName}' event during handle()"); } + + public function testUserAuthenticationSessionIsolation(): void + { + // first user logs in + $_COOKIE = ['PHPSESSID' => 'user1-session']; + $_POST = [ + 'username' => 'admin', + 'password' => 'admin', + ]; + $_SERVER = [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => 'site/login', + ]; + + $request1 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + + $response1 = $app->handle($request1); + + self::assertSame( + 200, + $response1->getStatusCode(), + "Response status code should be '200' for 'site/login' route in 'StatelessApplication'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response1->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/login' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 'PHPSESSID=user1-session; Path=/; HttpOnly; SameSite', + $response1->getHeaders()['Set-Cookie'][0] ?? '', + "Response 'Set-Cookie' header should contain 'user1-session' for 'site/login' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + <<getBody()->getContents(), + "Response body should contain valid JSON with 'status' and 'username' for successful login in " . + "'StatelessApplication'.", + ); + + // second user checks authentication status - should not be logged in + $_COOKIE = ['PHPSESSID' => 'user2-session']; + $_POST = []; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/checkauth', + ]; + + $request2 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response2 = $app->handle($request2); + + self::assertSame( + 200, + $response2->getStatusCode(), + "Response status code should be '200' for 'site/checkauth' route in 'StatelessApplication'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response2->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/checkauth' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + 'PHPSESSID=user2-session; Path=/; HttpOnly; SameSite', + $response2->getHeaders()['Set-Cookie'][0] ?? '', + "Response 'Set-Cookie' header should contain 'user2-session' for 'site/checkauth' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + <<getBody()->getContents(), + "Response body should indicate 'guest' status and 'null' identity for a new session in " . + "'StatelessApplication'.", + ); + } } diff --git a/tests/phpstan-config.php b/tests/phpstan-config.php index 3850d1fc..5bf89a41 100644 --- a/tests/phpstan-config.php +++ b/tests/phpstan-config.php @@ -5,6 +5,7 @@ use HttpSoft\Message\{ResponseFactory, StreamFactory}; use Psr\Http\Message\{ResponseFactoryInterface, StreamFactoryInterface}; use yii2\extensions\psrbridge\http\{ErrorHandler, Request, Response}; +use yii2\extensions\psrbridge\tests\support\stub\Identity; return [ 'components' => [ @@ -17,6 +18,9 @@ 'response' => [ 'class' => Response::class, ], + 'user' => [ + 'identityClass' => Identity::class, + ], ], 'container' => [ 'definitions' => [ diff --git a/tests/support/stub/Identity.php b/tests/support/stub/Identity.php new file mode 100644 index 00000000..0f215015 --- /dev/null +++ b/tests/support/stub/Identity.php @@ -0,0 +1,91 @@ + + */ + private static array $users = [ + '100' => [ + 'id' => '100', + 'username' => 'admin', + 'password' => 'admin', + 'authKey' => 'test100key', + 'accessToken' => '100-token', + ], + '101' => [ + 'id' => '101', + 'username' => 'demo', + 'password' => 'demo', + 'authKey' => 'test101key', + 'accessToken' => '101-token', + ], + ]; + + public static function findByUsername(string $username): Identity|null + { + foreach (self::$users as $user) { + if (strcasecmp($user['username'], $username) === 0) { + return new self($user); + } + } + + return null; + } + + public static function findIdentity($id): IdentityInterface|null + { + return isset(self::$users[$id]) ? new self(self::$users[$id]) : null; + } + + public static function findIdentityByAccessToken($token, $type = null): IdentityInterface|null + { + foreach (self::$users as $user) { + if ($user['accessToken'] === $token) { + return new self($user); + } + } + + return null; + } + + public function getAuthKey(): string + { + return $this->authKey; + } + + public function getId(): string + { + return $this->id; + } + + public function validateAuthKey($authKey): bool + { + return $this->authKey === $authKey; + } + + public function validatePassword(string $password): bool + { + return $this->password === $password; + } +} diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index 2a9aa260..606821ea 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -6,7 +6,9 @@ use Yii; use yii\base\Exception; +use yii\captcha\CaptchaAction; use yii\web\{Controller, Cookie, CookieCollection, Response}; +use yii\web\IdentityInterface; final class SiteController extends Controller { @@ -23,6 +25,22 @@ public function actionAuth(): array ]; } + /** + * @phpstan-return array{isGuest: bool, Identity?: string|null} + */ + public function actionCheckauth(): array + { + $this->response->format = Response::FORMAT_JSON; + + $user = Yii::$app->user; + $username = $user->identity instanceof Identity ? $user->identity->username : null; + + return [ + 'isGuest' => $user->isGuest, + 'identity' => $username, + ]; + } + public function actionCookie(): void { $this->response->cookies->add( @@ -97,6 +115,31 @@ public function actionIndex(): array return ['hello' => 'world']; } + /** + * @phpstan-return array{status: string, username?: string} + */ + public function actionLogin(): array + { + $this->response->format = Response::FORMAT_JSON; + + $username = $this->request->post('username'); + $password = $this->request->post('password'); + + if (is_string($username) && is_string($password)) { + $identity = Identity::findByUsername($username); + + if ($identity instanceof IdentityInterface === false || $identity->validatePassword($password) === false) { + return ['status' => 'error']; + } + + Yii::$app->user->login($identity); + + return ['status' => 'ok', 'username' => $username]; + } + + return ['status' => 'error']; + } + public function actionPost(): mixed { $this->response->format = Response::FORMAT_JSON; @@ -113,6 +156,16 @@ public function actionRefresh(): void { $this->response->refresh('#stateless'); } + public function actions(): array + { + return [ + 'captcha' => [ + 'class' => CaptchaAction::class, + 'minLength' => 4, + 'maxLength' => 6, + ], + ]; + } public function actionSetsession(): void { From 5962efbea5c228dd79c8883af07c5a15633ba726 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Tue, 29 Jul 2025 10:31:48 +0000 Subject: [PATCH 51/73] Apply fixes from StyleCI --- tests/support/stub/Identity.php | 2 +- tests/support/stub/SiteController.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/support/stub/Identity.php b/tests/support/stub/Identity.php index 0f215015..e2795418 100644 --- a/tests/support/stub/Identity.php +++ b/tests/support/stub/Identity.php @@ -42,7 +42,7 @@ final class Identity extends BaseObject implements IdentityInterface ], ]; - public static function findByUsername(string $username): Identity|null + public static function findByUsername(string $username): self|null { foreach (self::$users as $user) { if (strcasecmp($user['username'], $username) === 0) { diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index 606821ea..4c4b2f0d 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -156,6 +156,7 @@ public function actionRefresh(): void { $this->response->refresh('#stateless'); } + public function actions(): array { return [ From 155942a6ef5d1bb93ad2d10670a026bf75ab5361 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 06:34:45 -0400 Subject: [PATCH 52/73] feat: Add 'gd' extension to PHPUnit workflow for enhanced image processing capabilities. --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a11d7c0..a0c12f04 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,6 @@ jobs: phpunit: uses: php-forge/actions/.github/workflows/phpunit.yml@main with: - extensions: mbstring + extensions: mbstring, gd secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} From 07958da1dd39417b9fb0576e39c1a4ccfd62a917 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 07:32:53 -0400 Subject: [PATCH 53/73] fix: Update file streaming response to use a fixed filename instead of a temporary path. --- tests/TestCase.php | 6 +- tests/http/StatelessApplicationTest.php | 210 ++++++++++++++---------- tests/support/stub/SiteController.php | 2 +- 3 files changed, 129 insertions(+), 89 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index 137b1e9f..a4cf3919 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -57,7 +57,11 @@ protected function tearDown(): void protected function closeApplication(): void { if (Yii::$app->has('session')) { - Yii::$app->getSession()->close(); + $session = Yii::$app->getSession(); + if (session_status() === PHP_SESSION_ACTIVE) { + $session->destroy(); + $session->close(); + } } // ensure the logger is flushed after closing the application diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 74ed70a5..dbff31b4 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -5,9 +5,8 @@ namespace yii2\extensions\psrbridge\tests\http; use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; -use Psr\Http\Message\ResponseInterface; use Yii; -use yii\base\Security; +use yii\base\{InvalidConfigException, Security}; use yii\helpers\Json; use yii\i18n\{Formatter, I18N}; use yii\log\Dispatcher; @@ -17,6 +16,16 @@ use yii2\extensions\psrbridge\tests\support\FactoryHelper; use yii2\extensions\psrbridge\tests\TestCase; +use function array_filter; +use function array_key_exists; +use function explode; +use function ini_get; +use function ini_set; +use function sprintf; +use function str_starts_with; + +use const PHP_INT_MAX; + #[Group('http')] final class StatelessApplicationTest extends TestCase { @@ -27,6 +36,9 @@ protected function tearDown(): void parent::tearDown(); } + /** + * @throws InvalidConfigException + */ public function testCaptchaSessionIsolation(): void { // first user generates captcha - need to use refresh=1 to get JSON response @@ -51,7 +63,7 @@ public function testCaptchaSessionIsolation(): void "'StatelessApplication'.", ); - $captchaData1 = Json::decode($response1->getBody()->getContents(), true); + $captchaData1 = Json::decode($response1->getBody()->getContents()); self::assertIsArray( $captchaData1, @@ -87,7 +99,7 @@ public function testCaptchaSessionIsolation(): void $response2 = $app->handle($request2); - $captchaData2 = Json::decode($response2->getBody()->getContents(), true); + $captchaData2 = Json::decode($response2->getBody()->getContents()); self::assertIsArray( $captchaData2, @@ -174,6 +186,9 @@ public function testCaptchaSessionIsolation(): void ); } + /** + * @throws InvalidConfigException + */ public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void { $originalLimit = ini_get('memory_limit'); @@ -227,6 +242,9 @@ public function testRecalculateMemoryLimitAfterResetAndIniChange(): void ini_set('memory_limit', $originalLimit); } + /** + * @throws InvalidConfigException + */ public function testReturnCookiesHeadersForSiteCookieRoute(): void { $_SERVER = [ @@ -238,12 +256,6 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/cookie' route in " . - "'StatelessApplication'.", - ); self::assertSame( 200, $response->getStatusCode(), @@ -257,15 +269,12 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void if (str_starts_with($cookie, 'PHPSESSID=') === false) { $params = explode('; ', $cookie); - self::assertTrue( - in_array( - $params[0], - [ - 'test=test', - 'test2=test2', - ], - true, - ), + self::assertContains( + $params[0], + [ + 'test=test', + 'test2=test2', + ], sprintf( "Cookie header should contain either 'test=test' or 'test2=test2', got '%s' for 'site/cookie' " . 'route.', @@ -276,6 +285,9 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void } } + /** + * @throws InvalidConfigException + */ public function testReturnCoreComponentsConfigurationAfterHandle(): void { $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); @@ -329,6 +341,9 @@ public function testReturnCoreComponentsConfigurationAfterHandle(): void ); } + /** + * @throws InvalidConfigException + */ public function testReturnFalseFromCleanWhenMemoryUsageIsBelowThreshold(): void { $originalLimit = ini_get('memory_limit'); @@ -350,6 +365,9 @@ public function testReturnFalseFromCleanWhenMemoryUsageIsBelowThreshold(): void ini_set('memory_limit', $originalLimit); } + /** + * @throws InvalidConfigException + */ public function testReturnJsonResponseWithCookiesForSiteGetCookiesRoute(): void { $_COOKIE = [ @@ -364,12 +382,6 @@ public function testReturnJsonResponseWithCookiesForSiteGetCookiesRoute(): void $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/getcookies' route in " . - "'StatelessApplication'.", - ); self::assertSame( 200, $response->getStatusCode(), @@ -385,6 +397,9 @@ public function testReturnJsonResponseWithCookiesForSiteGetCookiesRoute(): void ); } + /** + * @throws InvalidConfigException + */ public function testReturnJsonResponseWithCredentialsForSiteAuthRoute(): void { $_SERVER = [ @@ -397,12 +412,6 @@ public function testReturnJsonResponseWithCredentialsForSiteAuthRoute(): void $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/auth' route in " . - "'StatelessApplication'.", - ); self::assertSame( 200, $response->getStatusCode(), @@ -418,6 +427,9 @@ public function testReturnJsonResponseWithCredentialsForSiteAuthRoute(): void ); } + /** + * @throws InvalidConfigException + */ public function testReturnJsonResponseWithNullCredentialsForMalformedAuthorizationHeader(): void { $_SERVER = [ @@ -430,12 +442,6 @@ public function testReturnJsonResponseWithNullCredentialsForMalformedAuthorizati $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/auth' route with malformed " . - "authorization header in 'StatelessApplication'.", - ); self::assertSame( 200, $response->getStatusCode(), @@ -452,6 +458,9 @@ public function testReturnJsonResponseWithNullCredentialsForMalformedAuthorizati ); } + /** + * @throws InvalidConfigException + */ public function testReturnJsonResponseWithPostParametersForSitePostRoute(): void { $_POST = [ @@ -469,12 +478,6 @@ public function testReturnJsonResponseWithPostParametersForSitePostRoute(): void $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/post' route in " . - "'StatelessApplication'.", - ); self::assertSame( 200, $response->getStatusCode(), @@ -490,6 +493,9 @@ public function testReturnJsonResponseWithPostParametersForSitePostRoute(): void ); } + /** + * @throws InvalidConfigException + */ public function testReturnJsonResponseWithQueryParametersForSiteGetRoute(): void { $_GET = [ @@ -507,12 +513,6 @@ public function testReturnJsonResponseWithQueryParametersForSiteGetRoute(): void $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/get' route in " . - "'StatelessApplication'.", - ); self::assertSame( 200, $response->getStatusCode(), @@ -528,6 +528,9 @@ public function testReturnJsonResponseWithQueryParametersForSiteGetRoute(): void ); } + /** + * @throws InvalidConfigException + */ public function testReturnPhpIntMaxWhenMemoryLimitIsUnlimited(): void { $originalLimit = ini_get('memory_limit'); @@ -556,6 +559,9 @@ public function testReturnPhpIntMaxWhenMemoryLimitIsUnlimited(): void ini_set('memory_limit', $originalLimit); } + /** + * @throws InvalidConfigException + */ public function testReturnPlainTextFileResponseForSiteFileRoute(): void { $_SERVER = [ @@ -567,12 +573,6 @@ public function testReturnPlainTextFileResponseForSiteFileRoute(): void $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/file' route in " . - "'StatelessApplication'.", - ); self::assertSame( 200, $response->getStatusCode(), @@ -600,6 +600,9 @@ public function testReturnPlainTextFileResponseForSiteFileRoute(): void ); } + /** + * @throws InvalidConfigException + */ public function testReturnPlainTextResponseWithFileContentForSiteStreamRoute(): void { $_SERVER = [ @@ -611,12 +614,6 @@ public function testReturnPlainTextResponseWithFileContentForSiteStreamRoute(): $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/stream' route in " . - "'StatelessApplication'.", - ); self::assertSame( 200, $response->getStatusCode(), @@ -635,6 +632,9 @@ public function testReturnPlainTextResponseWithFileContentForSiteStreamRoute(): ); } + /** + * @throws InvalidConfigException + */ public function testReturnRedirectResponseForSiteRedirectRoute(): void { $_SERVER = [ @@ -646,12 +646,6 @@ public function testReturnRedirectResponseForSiteRedirectRoute(): void $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/redirect' route in " . - "'StatelessApplication'.", - ); self::assertSame( 302, $response->getStatusCode(), @@ -665,6 +659,9 @@ public function testReturnRedirectResponseForSiteRedirectRoute(): void ); } + /** + * @throws InvalidConfigException + */ public function testReturnRedirectResponseForSiteRefreshRoute(): void { $_SERVER = [ @@ -676,12 +673,6 @@ public function testReturnRedirectResponseForSiteRefreshRoute(): void $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/refresh' route in " . - "'StatelessApplication'.", - ); self::assertSame( 302, $response->getStatusCode(), @@ -695,17 +686,15 @@ public function testReturnRedirectResponseForSiteRefreshRoute(): void ); } + /** + * @throws InvalidConfigException + */ public function testReturnsJsonResponse(): void { $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when 'handled()' by 'StatelessApplication'.", - ); self::assertSame( 200, $response->getStatusCode(), @@ -728,7 +717,10 @@ public function testReturnsJsonResponse(): void ); } - public function testReturnsStatusCode201ForSiteStatuscodeRoute(): void + /** + * @throws InvalidConfigException + */ + public function testReturnsStatusCode201ForSiteStatusCodeRoute(): void { $_SERVER = [ 'REQUEST_METHOD' => 'GET', @@ -739,12 +731,6 @@ public function testReturnsStatusCode201ForSiteStatuscodeRoute(): void $response = $this->statelessApplication()->handle($request); - self::assertInstanceOf( - ResponseInterface::class, - $response, - "Response should be an instance of 'ResponseInterface' when handling 'site/statuscode' route in " . - "'StatelessApplication'.", - ); self::assertSame( 201, $response->getStatusCode(), @@ -752,6 +738,9 @@ public function testReturnsStatusCode201ForSiteStatuscodeRoute(): void ); } + /** + * @throws InvalidConfigException + */ public function testSessionDataPersistenceWithSameSessionId(): void { $sessionId = 'test-session-' . uniqid(); @@ -816,7 +805,7 @@ public function testSessionDataPersistenceWithSameSessionId(): void "'StatelessApplication'.", ); - $body = Json::decode($response2->getBody()->getContents(), true); + $body = Json::decode($response2->getBody()->getContents()); self::assertIsArray( $body, @@ -837,6 +826,9 @@ public function testSessionDataPersistenceWithSameSessionId(): void ); } + /** + * @throws InvalidConfigException + */ public function testSessionIsolationBetweenRequests(): void { $_COOKIE = ['PHPSESSID' => 'session-user-a']; @@ -899,7 +891,7 @@ public function testSessionIsolationBetweenRequests(): void "'StatelessApplication'.", ); - $body = Json::decode($response2->getBody()->getContents(), true); + $body = Json::decode($response2->getBody()->getContents()); self::assertIsArray( $body, @@ -920,6 +912,44 @@ public function testSessionIsolationBetweenRequests(): void ); } + /** + * @throws InvalidConfigException + */ + public function testSessionWithoutCookieCreatesNewSession(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/getsession', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + + $response = $app->handle($request); + $cookies = $response->getHeaders()['Set-Cookie'] ?? []; + $cookie = array_filter( + $cookies, + static fn(string $cookie): bool => str_starts_with($cookie, 'PHPSESSID='), + ); + + self::assertCount( + 1, + $cookie, + "Response 'Set-Cookie' header should contain exactly one 'PHPSESSID' cookie when no session cookie is " . + "sent in 'StatelessApplication'.", + ); + self::assertMatchesRegularExpression( + '/^PHPSESSID=[a-f0-9]+; Path=\/; HttpOnly; SameSite$/', + $cookie[0] ?? '', + "Response 'Set-Cookie' header should match the expected format for a new session 'ID' when no session " . + "cookie is sent in 'StatelessApplication'. Value received: '" . ($cookie[0] ?? '') . "'.", + ); + } + + /** + * @throws InvalidConfigException + */ public function testSetWebAndWebrootAliasesAfterHandleRequest(): void { $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); @@ -941,6 +971,9 @@ public function testSetWebAndWebrootAliasesAfterHandleRequest(): void ); } + /** + * @throws InvalidConfigException + */ #[DataProviderExternal(StatelessApplicationProvider::class, 'eventDataProvider')] public function testTriggerEventDuringHandle(string $eventName): void { @@ -962,6 +995,9 @@ static function () use (&$eventTriggered): void { self::assertTrue($eventTriggered, "Should trigger '{$eventName}' event during handle()"); } + /** + * @throws InvalidConfigException + */ public function testUserAuthenticationSessionIsolation(): void { // first user logs in diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index 4c4b2f0d..3e347e0f 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -197,6 +197,6 @@ public function actionStream(): Response $tmpFilePath = stream_get_meta_data($tmpFile)['uri']; - return $this->response->sendStreamAsFile($tmpFile, $tmpFilePath, ['mimeType' => 'text/plain']); + return $this->response->sendStreamAsFile($tmpFile, 'stream.txt', ['mimeType' => 'text/plain']); } } From c5ab8b46f598e7c10aab6db8d21ddd8e48a5622c Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 07:35:13 -0400 Subject: [PATCH 54/73] feat: Set session save path in TestCase for improved session management. --- tests/TestCase.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index a4cf3919..02d09dd3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -144,6 +144,7 @@ protected function statelessApplication($config = []): StatelessApplication ], 'session' => [ 'name' => 'PHPSESSID', + 'savePath' => dirname(__DIR__) . '/runtime', ], 'user' => [ 'enableAutoLogin' => false, From 5fdbcf8d4ca2ffdbd6d44f8ae814b81e7478ccc9 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 07:41:52 -0400 Subject: [PATCH 55/73] feat: Update session handling in TestCase to use dedicated session directory. --- tests/TestCase.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index 02d09dd3..030dc42e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -9,7 +9,7 @@ use RuntimeException; use Yii; use yii\caching\FileCache; -use yii\helpers\ArrayHelper; +use yii\helpers\{ArrayHelper, FileHelper}; use yii\log\FileTarget; use yii\web\JsonParser; use yii2\extensions\psrbridge\http\StatelessApplication; @@ -36,6 +36,8 @@ protected function setUp(): void { parent::setUp(); + FileHelper::createDirectory(dirname(__DIR__) . '/runtime/sessions', 0777, true); + $this->originalServer = $_SERVER; $_SERVER = []; @@ -64,6 +66,8 @@ protected function closeApplication(): void } } + FileHelper::removeDirectory(dirname(__DIR__) . '/runtime/sessions'); + // ensure the logger is flushed after closing the application $logger = Yii::getLogger(); $logger->flush(); @@ -144,7 +148,7 @@ protected function statelessApplication($config = []): StatelessApplication ], 'session' => [ 'name' => 'PHPSESSID', - 'savePath' => dirname(__DIR__) . '/runtime', + 'savePath' => dirname(__DIR__) . '/runtime/sessions', ], 'user' => [ 'enableAutoLogin' => false, From 812acc03fff2d5aa8efde6b23ac5e91dcf398fdf Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 07:45:55 -0400 Subject: [PATCH 56/73] fix: Ensure proper session cleanup in `tearDown()` method of `TestCase` class. --- tests/TestCase.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index 030dc42e..be4c6e4e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -49,6 +49,14 @@ protected function tearDown(): void $_FILES = []; $_GET = []; $_POST = []; + + // reset and destroy any active PHP session + if (session_status() === PHP_SESSION_ACTIVE) { + session_unset(); + session_destroy(); + } + + $_SESSION = []; $_SERVER = $this->originalServer; $this->closeTmpFile(...$this->tmpFiles); From d77535bddb49eab36c24c4f6eb0d3b8a96658974 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 07:50:03 -0400 Subject: [PATCH 57/73] fix: Simplify session cleanup in `tearDown()` method of `TestCase` class. --- tests/TestCase.php | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index be4c6e4e..41c0cffb 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -45,17 +45,12 @@ protected function setUp(): void protected function tearDown(): void { + FileHelper::removeDirectory(dirname(__DIR__) . '/runtime/sessions'); + $_COOKIE = []; $_FILES = []; $_GET = []; $_POST = []; - - // reset and destroy any active PHP session - if (session_status() === PHP_SESSION_ACTIVE) { - session_unset(); - session_destroy(); - } - $_SESSION = []; $_SERVER = $this->originalServer; @@ -74,8 +69,6 @@ protected function closeApplication(): void } } - FileHelper::removeDirectory(dirname(__DIR__) . '/runtime/sessions'); - // ensure the logger is flushed after closing the application $logger = Yii::getLogger(); $logger->flush(); From 22e522dee79a0592faa4a4a844d3aff61dc9eded Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 08:05:36 -0400 Subject: [PATCH 58/73] fix: Simplify session `ID` retrieval in `StatelessApplication` class by removing unnecessary type check. --- src/http/StatelessApplication.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index a50e81df..87b1c3c9 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -17,7 +17,6 @@ use function function_exists; use function gc_collect_cycles; use function ini_get; -use function is_string; use function memory_get_usage; use function method_exists; use function microtime; @@ -379,11 +378,8 @@ protected function reset(ServerRequestInterface $request): void $this->request->setPsr7Request($request); $this->session->close(); - $sessionId = $this->request->getCookies()->get($this->session->getName())->value ?? null; - - if (is_string($sessionId)) { - $this->session->setId($sessionId); - } + $sessionId = $this->request->getCookies()->get($this->session->getName())->value ?? ''; + $this->session->setId($sessionId); // start the session with the correct 'ID' $this->session->open(); From 1cbb9aa23ff064a32f8f7512d4f0f62fca2262d5 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 08:18:09 -0400 Subject: [PATCH 59/73] fix: Update regex for session ID validation to allow alphanumeric characters in 'Set-Cookie' header. --- tests/http/StatelessApplicationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index dbff31b4..2338dcf1 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -940,7 +940,7 @@ public function testSessionWithoutCookieCreatesNewSession(): void "sent in 'StatelessApplication'.", ); self::assertMatchesRegularExpression( - '/^PHPSESSID=[a-f0-9]+; Path=\/; HttpOnly; SameSite$/', + '/^PHPSESSID=[a-zA-Z0-9]+; Path=\/; HttpOnly; SameSite$/', $cookie[0] ?? '', "Response 'Set-Cookie' header should match the expected format for a new session 'ID' when no session " . "cookie is sent in 'StatelessApplication'. Value received: '" . ($cookie[0] ?? '') . "'.", From 93cd36df36d66c6f2333471a51a8c96df17fde68 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 08:49:26 -0400 Subject: [PATCH 60/73] fix: Update session `ID` retrieval to use a new session `ID` if none is found in cookies. --- src/http/StatelessApplication.php | 11 +++++++++-- tests/TestCase.php | 11 +++-------- tests/support/stub/SiteController.php | 2 -- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 87b1c3c9..54401306 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -17,9 +17,13 @@ use function function_exists; use function gc_collect_cycles; use function ini_get; +use function is_string; use function memory_get_usage; use function method_exists; use function microtime; +use function session_create_id; +use function session_status; +use function session_write_close; use function sscanf; use function strtoupper; use function uopz_redefine; @@ -378,8 +382,11 @@ protected function reset(ServerRequestInterface $request): void $this->request->setPsr7Request($request); $this->session->close(); - $sessionId = $this->request->getCookies()->get($this->session->getName())->value ?? ''; - $this->session->setId($sessionId); + $sessionId = $this->request->getCookies()->get($this->session->getName())->value ?? session_create_id(); + + if (is_string($sessionId)) { + $this->session->setId($sessionId); + } // start the session with the correct 'ID' $this->session->open(); diff --git a/tests/TestCase.php b/tests/TestCase.php index 41c0cffb..aeddf18b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -9,7 +9,7 @@ use RuntimeException; use Yii; use yii\caching\FileCache; -use yii\helpers\{ArrayHelper, FileHelper}; +use yii\helpers\ArrayHelper; use yii\log\FileTarget; use yii\web\JsonParser; use yii2\extensions\psrbridge\http\StatelessApplication; @@ -36,8 +36,6 @@ protected function setUp(): void { parent::setUp(); - FileHelper::createDirectory(dirname(__DIR__) . '/runtime/sessions', 0777, true); - $this->originalServer = $_SERVER; $_SERVER = []; @@ -45,13 +43,10 @@ protected function setUp(): void protected function tearDown(): void { - FileHelper::removeDirectory(dirname(__DIR__) . '/runtime/sessions'); - $_COOKIE = []; $_FILES = []; $_GET = []; $_POST = []; - $_SESSION = []; $_SERVER = $this->originalServer; $this->closeTmpFile(...$this->tmpFiles); @@ -63,7 +58,8 @@ protected function closeApplication(): void { if (Yii::$app->has('session')) { $session = Yii::$app->getSession(); - if (session_status() === PHP_SESSION_ACTIVE) { + + if ($session->getIsActive()) { $session->destroy(); $session->close(); } @@ -149,7 +145,6 @@ protected function statelessApplication($config = []): StatelessApplication ], 'session' => [ 'name' => 'PHPSESSID', - 'savePath' => dirname(__DIR__) . '/runtime/sessions', ], 'user' => [ 'enableAutoLogin' => false, diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index 3e347e0f..7c83dcb9 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -195,8 +195,6 @@ public function actionStream(): Response fwrite($tmpFile, 'This is a test file content.'); rewind($tmpFile); - $tmpFilePath = stream_get_meta_data($tmpFile)['uri']; - return $this->response->sendStreamAsFile($tmpFile, 'stream.txt', ['mimeType' => 'text/plain']); } } From 34945deefc9f476dc5c7a2ce0679a97427fc24d1 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 08:51:11 -0400 Subject: [PATCH 61/73] fix: Add `session_create_id` to symbol whitelist in composer require checker. --- composer-require-checker.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer-require-checker.json b/composer-require-checker.json index 77caf405..bc6f9b3f 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -1,6 +1,7 @@ { "symbol-whitelist": [ "PHP_SESSION_ACTIVE", + "session_create_id", "session_status", "session_write_close", "uopz_redefine", From 95f22cc0aa17f75f417625089c07fddc06c5a7ca Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 09:09:24 -0400 Subject: [PATCH 62/73] fix: Refactor authorization token handling and improve identity validation in `SiteController` class. --- src/http/Request.php | 21 ++++++++++++++------- src/http/StatelessApplication.php | 1 + tests/support/stub/SiteController.php | 22 +++++++++++++++++++--- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/http/Request.php b/src/http/Request.php index a5738d30..492b11e6 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -10,7 +10,14 @@ use yii2\extensions\psrbridge\adapter\ServerRequestAdapter; use yii2\extensions\psrbridge\exception\Message; +use function base64_decode; +use function count; +use function explode; use function is_array; +use function is_string; +use function mb_check_encoding; +use function mb_substr; +use function strncasecmp; /** * HTTP Request extension with PSR-7 bridge and worker mode support. @@ -104,27 +111,27 @@ public function getAuthCredentials(): array * --OR-- * RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] */ - $auth_token = $this->getHeaders()->get('Authorization'); + $authToken = $this->getHeaders()->get('Authorization'); /** @phpstan-ignore-next-line */ - if ($auth_token !== null && strncasecmp($auth_token, 'basic', 5) === 0) { - $encoded = mb_substr($auth_token, 6); + if ($authToken !== null && strncasecmp($authToken, 'basic', 5) === 0) { + $encoded = mb_substr($authToken, 6); $decoded = base64_decode($encoded, true); // strict mode // validate decoded data - if ($decoded === false || !mb_check_encoding($decoded, 'UTF-8')) { + if ($decoded === false || mb_check_encoding($decoded, 'UTF-8') === false) { return [null, null]; // return null for malformed credentials } $parts = explode(':', $decoded, 2); if (count($parts) < 2) { - return [strlen($parts[0]) === 0 ? null : $parts[0], null]; + return [$parts[0] === '' ? null : $parts[0], null]; } return [ - strlen($parts[0]) === 0 ? null : $parts[0], - (isset($parts[1]) && strlen($parts[1]) !== 0) ? $parts[1] : null, + $parts[0] === '' ? null : $parts[0], + (isset($parts[1]) && $parts[1] !== '') ? $parts[1] : null, ]; } diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 54401306..77119ef8 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -372,6 +372,7 @@ protected function reset(ServerRequestInterface $request): void $this->errorHandler->unregister(); } + // parent constructor is called because StatelessApplication uses a custom initialization pattern // @phpstan-ignore-next-line parent::__construct($config); diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index 7c83dcb9..f026a75b 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -6,9 +6,15 @@ use Yii; use yii\base\Exception; +use yii\base\InvalidRouteException; use yii\captcha\CaptchaAction; -use yii\web\{Controller, Cookie, CookieCollection, Response}; -use yii\web\IdentityInterface; +use yii\web\{Controller, Cookie, CookieCollection, RangeNotSatisfiableHttpException, Response}; + +use function fwrite; +use function is_string; +use function rewind; +use function stream_get_meta_data; +use function tmpfile; final class SiteController extends Controller { @@ -63,6 +69,9 @@ public function actionCookie(): void ); } + /** + * @throws Exception + */ public function actionFile(): Response { $this->response->format = Response::FORMAT_RAW; @@ -128,7 +137,7 @@ public function actionLogin(): array if (is_string($username) && is_string($password)) { $identity = Identity::findByUsername($username); - if ($identity instanceof IdentityInterface === false || $identity->validatePassword($password) === false) { + if ($identity === null || $identity->validatePassword($password) === false) { return ['status' => 'error']; } @@ -147,6 +156,9 @@ public function actionPost(): mixed return $this->request->post(); } + /** + * @throws InvalidRouteException + */ public function actionRedirect(): void { $this->response->redirect('/site/index'); @@ -182,6 +194,10 @@ public function actionStatuscode(): void $this->response->statusCode = 201; } + /** + * @throws Exception + * @throws RangeNotSatisfiableHttpException + */ public function actionStream(): Response { $this->response->format = Response::FORMAT_RAW; From 54d1a409aa375b95fa11f90de61ce478c4100052 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 12:50:36 -0400 Subject: [PATCH 63/73] test: Add session handling tests for multiple requests in worker mode and implement session data actions in `SiteController`. --- tests/http/StatelessApplicationTest.php | 58 +++++++++++++++++++++++++ tests/support/stub/SiteController.php | 21 +++++++++ 2 files changed, 79 insertions(+) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 2338dcf1..2065db42 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -211,6 +211,64 @@ public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void ini_set('memory_limit', $originalLimit); } + public function testMultipleRequestsWithDifferentSessionsInWorkerMode(): void + { + $app = $this->statelessApplication(); + + $sessions = []; + + for ($i = 1; $i <= 3; $i++) { + $sessionId = "worker-session-{$i}"; + $_COOKIE = ['PHPSESSID' => $sessionId]; + $_POST = ['data' => "user-{$i}-data"]; + $_SERVER = [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => 'site/setsessiondata', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app->handle($request); + + $sessions[] = $sessionId; + } + + foreach ($sessions as $index => $sessionId) { + $_COOKIE = ['PHPSESSID' => $sessionId]; + $_POST = []; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/getsessiondata', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response = $app->handle($request); + $data = Json::decode($response->getBody()->getContents()); + + $expectedData = 'user-' . ($index + 1) . '-data'; + + self::assertIsArray( + $data, + sprintf( + "Response body should be an array after decoding JSON for session '%s' in multiple requests with " . + 'different sessions in worker mode.', + $sessionId, + ), + ); + self::assertSame( + $expectedData, + $data['data'] ?? null, + sprintf( + "Session '%s' should return its own data ('%s') and not leak data between sessions in multiple " . + 'requests with different sessions in worker mode.', + $sessionId, + $expectedData, + ), + ); + } + } + public function testRecalculateMemoryLimitAfterResetAndIniChange(): void { $originalLimit = ini_get('memory_limit'); diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index f026a75b..d53c919c 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -114,6 +114,16 @@ public function actionGetsession(): array return ['testValue' => Yii::$app->session->get('testValue')]; } + /** + * @phpstan-return array{data: mixed} + */ + public function actionGetsessiondata(): array + { + $this->response->format = Response::FORMAT_JSON; + + return ['data' => Yii::$app->session->get('userData')]; + } + /** * @phpstan-return string[] */ @@ -189,6 +199,17 @@ public function actionSetsession(): void $this->response->data = ['status' => 'ok']; } + public function actionSetsessiondata(): void + { + $this->response->format = Response::FORMAT_JSON; + + $data = $this->request->post('data'); + + Yii::$app->session->set('userData', $data); + + $this->response->data = ['status' => 'ok']; + } + public function actionStatuscode(): void { $this->response->statusCode = 201; From 24891fec4b2b57a4543b6a07b1ac0a3c138a77f3 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 17:04:52 -0400 Subject: [PATCH 64/73] fix: Simplify session handling by removing redundant checks and clearing session data. --- composer-require-checker.json | 4 ---- src/http/StatelessApplication.php | 18 ++---------------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/composer-require-checker.json b/composer-require-checker.json index bc6f9b3f..50a54a46 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -1,9 +1,5 @@ { "symbol-whitelist": [ - "PHP_SESSION_ACTIVE", - "session_create_id", - "session_status", - "session_write_close", "uopz_redefine", "YII_DEBUG" ] diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 77119ef8..d89bf7ef 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -17,13 +17,9 @@ use function function_exists; use function gc_collect_cycles; use function ini_get; -use function is_string; use function memory_get_usage; use function method_exists; use function microtime; -use function session_create_id; -use function session_status; -use function session_write_close; use function sscanf; use function strtoupper; use function uopz_redefine; @@ -359,13 +355,6 @@ protected function reset(ServerRequestInterface $request): void $this->startEventTracking(); - if (session_status() === PHP_SESSION_ACTIVE) { - session_write_close(); - } - - // clear the global session array to prevent data leakage - $_SESSION = []; - $config = $this->config; if ($this->has('errorHandler')) { @@ -383,11 +372,8 @@ protected function reset(ServerRequestInterface $request): void $this->request->setPsr7Request($request); $this->session->close(); - $sessionId = $this->request->getCookies()->get($this->session->getName())->value ?? session_create_id(); - - if (is_string($sessionId)) { - $this->session->setId($sessionId); - } + $sessionId = $this->request->getCookies()->get($this->session->getName())->value ?? ''; + $this->session->setId($sessionId); // start the session with the correct 'ID' $this->session->open(); From 21c6134c103db6562f555d49f8f4945dd5c30c03 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 17:13:57 -0400 Subject: [PATCH 65/73] fix: Update session handling in tests to use dynamic session name and return cookie array in `SiteController` class. --- tests/TestCase.php | 3 --- tests/http/StatelessApplicationTest.php | 10 ++++++++-- tests/support/stub/SiteController.php | 7 +++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index aeddf18b..673678f7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -143,9 +143,6 @@ protected function statelessApplication($config = []): StatelessApplication 'response' => [ 'charset' => 'UTF-8', ], - 'session' => [ - 'name' => 'PHPSESSID', - ], 'user' => [ 'enableAutoLogin' => false, 'identityClass' => Identity::class, diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 2065db42..8577a427 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -997,8 +997,11 @@ public function testSessionWithoutCookieCreatesNewSession(): void "Response 'Set-Cookie' header should contain exactly one 'PHPSESSID' cookie when no session cookie is " . "sent in 'StatelessApplication'.", ); + + $sessionName = $app->session->getName(); + self::assertMatchesRegularExpression( - '/^PHPSESSID=[a-zA-Z0-9]+; Path=\/; HttpOnly; SameSite$/', + '/^' . preg_quote($sessionName, '/') . '=[a-zA-Z0-9]+; Path=\/; HttpOnly; SameSite$/', $cookie[0] ?? '', "Response 'Set-Cookie' header should match the expected format for a new session 'ID' when no session " . "cookie is sent in 'StatelessApplication'. Value received: '" . ($cookie[0] ?? '') . "'.", @@ -1086,8 +1089,11 @@ public function testUserAuthenticationSessionIsolation(): void "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/login' route in " . "'StatelessApplication'.", ); + + $sessionName = $app->session->getName(); + self::assertSame( - 'PHPSESSID=user1-session; Path=/; HttpOnly; SameSite', + "{$sessionName}=user1-session; Path=/; HttpOnly; SameSite", $response1->getHeaders()['Set-Cookie'][0] ?? '', "Response 'Set-Cookie' header should contain 'user1-session' for 'site/login' route in " . "'StatelessApplication'.", diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index d53c919c..8425a35c 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -97,11 +97,14 @@ public function actionGet(): mixed return $this->request->get(); } - public function actionGetcookies(): CookieCollection + /** + * @phpstan-return Cookie[] + */ + public function actionGetcookies(): array { $this->response->format = Response::FORMAT_JSON; - return $this->request->getCookies(); + return $this->request->getCookies()->toArray(); } /** From 190b46922622c6f25a1a315aae483a6914860519 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Tue, 29 Jul 2025 21:14:18 +0000 Subject: [PATCH 66/73] Apply fixes from StyleCI --- tests/support/stub/SiteController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index 8425a35c..42c5ca29 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -8,7 +8,7 @@ use yii\base\Exception; use yii\base\InvalidRouteException; use yii\captcha\CaptchaAction; -use yii\web\{Controller, Cookie, CookieCollection, RangeNotSatisfiableHttpException, Response}; +use yii\web\{Controller, Cookie, RangeNotSatisfiableHttpException, Response}; use function fwrite; use function is_string; From 31a909e62645cbe697aeb3b58a1b6720fd2fa8ec Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 17:30:04 -0400 Subject: [PATCH 67/73] fix: Update session handling in `StatelessApplicationTest` to use dynamic session names in `Set-Cookie` headers. --- tests/http/StatelessApplicationTest.php | 43 ++++++++++++++++++------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 8577a427..a04b497d 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -173,8 +173,11 @@ public function testCaptchaSessionIsolation(): void $response3->getHeaders()['content-type'][0], "Captcha image response 'content-type' should be 'image/png' for '{$url}' in 'StatelessApplication'.", ); + + $sessionName = $app->session->getName(); + self::assertSame( - 'PHPSESSID=user-a-session; Path=/; HttpOnly; SameSite', + "{$sessionName}=user-a-session; Path=/; HttpOnly; SameSite", $response3->getHeaders()['Set-Cookie'][0] ?? '', "Captcha image response 'Set-Cookie' should contain 'user-a-session' for '{$url}' in " . "'StatelessApplication'.", @@ -312,7 +315,9 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); - $response = $this->statelessApplication()->handle($request); + $app = $this->statelessApplication(); + + $response = $app->handle($request); self::assertSame( 200, @@ -324,7 +329,7 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void foreach ($cookies as $cookie) { // skip the last cookie header (assumed to be 'PHPSESSION'). - if (str_starts_with($cookie, 'PHPSESSID=') === false) { + if (str_starts_with($cookie, $app->session->getName()) === false) { $params = explode('; ', $cookie); self::assertContains( @@ -827,8 +832,11 @@ public function testSessionDataPersistenceWithSameSessionId(): void "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/setsession' route in " . "'StatelessApplication'.", ); + + $sessionName = $app->session->getName(); + self::assertSame( - "PHPSESSID={$sessionId}; Path=/; HttpOnly; SameSite", + "{$sessionName}={$sessionId}; Path=/; HttpOnly; SameSite", $response1->getHeaders()['Set-Cookie'][0] ?? '', "Response 'Set-Cookie' header should contain '{$sessionId}' for 'site/setsession' route in " . "'StatelessApplication'.", @@ -856,8 +864,11 @@ public function testSessionDataPersistenceWithSameSessionId(): void "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/getsession' route in " . "'StatelessApplication'.", ); + + $sessionName = $app->session->getName(); + self::assertSame( - "PHPSESSID={$sessionId}; Path=/; HttpOnly; SameSite", + "{$sessionName}={$sessionId}; Path=/; HttpOnly; SameSite", $response2->getHeaders()['Set-Cookie'][0] ?? '', "Response 'Set-Cookie' header should contain '{$sessionId}' for 'site/getsession' route in " . "'StatelessApplication'.", @@ -913,8 +924,11 @@ public function testSessionIsolationBetweenRequests(): void "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/setsession' route in " . "'StatelessApplication'.", ); + + $sessionName = $app->session->getName(); + self::assertSame( - 'PHPSESSID=session-user-a; Path=/; HttpOnly; SameSite', + "{$sessionName}=session-user-a; Path=/; HttpOnly; SameSite", $response1->getHeaders()['Set-Cookie'][0] ?? '', "Response 'Set-Cookie' header should contain 'session-user-a' for 'site/setsession' route in " . "'StatelessApplication'.", @@ -942,8 +956,11 @@ public function testSessionIsolationBetweenRequests(): void "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/getsession' route in " . "'StatelessApplication'.", ); + + $sessionName = $app->session->getName(); + self::assertSame( - 'PHPSESSID=session-user-b; Path=/; HttpOnly; SameSite', + "{$sessionName}=session-user-b; Path=/; HttpOnly; SameSite", $response2->getHeaders()['Set-Cookie'][0] ?? '', "Response 'Set-Cookie' header should contain 'session-user-b' for 'site/getsession' route in " . "'StatelessApplication'.", @@ -986,16 +1003,17 @@ public function testSessionWithoutCookieCreatesNewSession(): void $response = $app->handle($request); $cookies = $response->getHeaders()['Set-Cookie'] ?? []; + $sessionName = $app->session->getName(); $cookie = array_filter( $cookies, - static fn(string $cookie): bool => str_starts_with($cookie, 'PHPSESSID='), + static fn(string $cookie): bool => str_starts_with($cookie, "{$sessionName}="), ); self::assertCount( 1, $cookie, - "Response 'Set-Cookie' header should contain exactly one 'PHPSESSID' cookie when no session cookie is " . - "sent in 'StatelessApplication'.", + "Response 'Set-Cookie' header should contain exactly one '{$sessionName}' cookie when no session cookie " . + "is sent in 'StatelessApplication'.", ); $sessionName = $app->session->getName(); @@ -1130,8 +1148,11 @@ public function testUserAuthenticationSessionIsolation(): void "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/checkauth' route in " . "'StatelessApplication'.", ); + + $sessionName = $app->session->getName(); + self::assertSame( - 'PHPSESSID=user2-session; Path=/; HttpOnly; SameSite', + "{$sessionName}=user2-session; Path=/; HttpOnly; SameSite", $response2->getHeaders()['Set-Cookie'][0] ?? '', "Response 'Set-Cookie' header should contain 'user2-session' for 'site/checkauth' route in " . "'StatelessApplication'.", From 8fa33591605b0fd65950bd526921380854747b37 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 17:52:17 -0400 Subject: [PATCH 68/73] fix: Update session handling in `StatelessApplicationTest` to use dynamic session names for cookie management. --- tests/http/StatelessApplicationTest.php | 38 ++++++++----------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index a04b497d..94f7772f 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -21,6 +21,7 @@ use function explode; use function ini_get; use function ini_set; +use function session_name; use function sprintf; use function str_starts_with; @@ -41,8 +42,10 @@ protected function tearDown(): void */ public function testCaptchaSessionIsolation(): void { + $sessionName = session_name(); + // first user generates captcha - need to use refresh=1 to get JSON response - $_COOKIE = ['PHPSESSID' => 'user-a-session']; + $_COOKIE = [$sessionName => 'user-a-session']; $_GET = ['refresh' => '1']; $_SERVER = [ 'QUERY_STRING' => 'refresh=1', @@ -174,8 +177,6 @@ public function testCaptchaSessionIsolation(): void "Captcha image response 'content-type' should be 'image/png' for '{$url}' in 'StatelessApplication'.", ); - $sessionName = $app->session->getName(); - self::assertSame( "{$sessionName}=user-a-session; Path=/; HttpOnly; SameSite", $response3->getHeaders()['Set-Cookie'][0] ?? '', @@ -806,9 +807,10 @@ public function testReturnsStatusCode201ForSiteStatusCodeRoute(): void */ public function testSessionDataPersistenceWithSameSessionId(): void { + $sessionName = session_name(); $sessionId = 'test-session-' . uniqid(); - $_COOKIE = ['PHPSESSID' => $sessionId]; + $_COOKIE = [$sessionName => $sessionId]; $_SERVER = [ 'REQUEST_METHOD' => 'GET', 'REQUEST_URI' => 'site/setsession', @@ -833,8 +835,6 @@ public function testSessionDataPersistenceWithSameSessionId(): void "'StatelessApplication'.", ); - $sessionName = $app->session->getName(); - self::assertSame( "{$sessionName}={$sessionId}; Path=/; HttpOnly; SameSite", $response1->getHeaders()['Set-Cookie'][0] ?? '', @@ -864,9 +864,6 @@ public function testSessionDataPersistenceWithSameSessionId(): void "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/getsession' route in " . "'StatelessApplication'.", ); - - $sessionName = $app->session->getName(); - self::assertSame( "{$sessionName}={$sessionId}; Path=/; HttpOnly; SameSite", $response2->getHeaders()['Set-Cookie'][0] ?? '', @@ -900,7 +897,9 @@ public function testSessionDataPersistenceWithSameSessionId(): void */ public function testSessionIsolationBetweenRequests(): void { - $_COOKIE = ['PHPSESSID' => 'session-user-a']; + $sessionName = session_name(); + + $_COOKIE = [$sessionName => 'session-user-a']; $_SERVER = [ 'REQUEST_METHOD' => 'GET', 'REQUEST_URI' => 'site/setsession', @@ -924,9 +923,6 @@ public function testSessionIsolationBetweenRequests(): void "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/setsession' route in " . "'StatelessApplication'.", ); - - $sessionName = $app->session->getName(); - self::assertSame( "{$sessionName}=session-user-a; Path=/; HttpOnly; SameSite", $response1->getHeaders()['Set-Cookie'][0] ?? '', @@ -956,9 +952,6 @@ public function testSessionIsolationBetweenRequests(): void "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/getsession' route in " . "'StatelessApplication'.", ); - - $sessionName = $app->session->getName(); - self::assertSame( "{$sessionName}=session-user-b; Path=/; HttpOnly; SameSite", $response2->getHeaders()['Set-Cookie'][0] ?? '', @@ -1015,9 +1008,6 @@ public function testSessionWithoutCookieCreatesNewSession(): void "Response 'Set-Cookie' header should contain exactly one '{$sessionName}' cookie when no session cookie " . "is sent in 'StatelessApplication'.", ); - - $sessionName = $app->session->getName(); - self::assertMatchesRegularExpression( '/^' . preg_quote($sessionName, '/') . '=[a-zA-Z0-9]+; Path=\/; HttpOnly; SameSite$/', $cookie[0] ?? '', @@ -1079,8 +1069,10 @@ static function () use (&$eventTriggered): void { */ public function testUserAuthenticationSessionIsolation(): void { + $sessionName = session_name(); + // first user logs in - $_COOKIE = ['PHPSESSID' => 'user1-session']; + $_COOKIE = [$sessionName => 'user1-session']; $_POST = [ 'username' => 'admin', 'password' => 'admin', @@ -1107,9 +1099,6 @@ public function testUserAuthenticationSessionIsolation(): void "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/login' route in " . "'StatelessApplication'.", ); - - $sessionName = $app->session->getName(); - self::assertSame( "{$sessionName}=user1-session; Path=/; HttpOnly; SameSite", $response1->getHeaders()['Set-Cookie'][0] ?? '', @@ -1148,9 +1137,6 @@ public function testUserAuthenticationSessionIsolation(): void "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/checkauth' route in " . "'StatelessApplication'.", ); - - $sessionName = $app->session->getName(); - self::assertSame( "{$sessionName}=user2-session; Path=/; HttpOnly; SameSite", $response2->getHeaders()['Set-Cookie'][0] ?? '', From 680f7b7d7f91068f39a61547b68bcb34a123d057 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 18:00:15 -0400 Subject: [PATCH 69/73] fix: Update session handling in `StatelessApplicationTest` to use dynamic session names for cookie management. --- tests/http/StatelessApplicationTest.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 94f7772f..43046653 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -90,7 +90,7 @@ public function testCaptchaSessionIsolation(): void ); // second user requests captcha - should get different data - $_COOKIE = ['PHPSESSID' => 'user-b-session']; + $_COOKIE = [$sessionName => 'user-b-session']; $_GET = ['refresh' => '1']; $_SERVER = [ 'REQUEST_METHOD' => 'GET', @@ -143,7 +143,7 @@ public function testCaptchaSessionIsolation(): void "'StatelessApplication'.", ); - $_COOKIE = ['PHPSESSID' => 'user-a-session']; + $_COOKIE = [$sessionName => 'user-a-session']; $_GET = []; $_SERVER = [ 'REQUEST_METHOD' => 'GET', @@ -217,13 +217,15 @@ public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void public function testMultipleRequestsWithDifferentSessionsInWorkerMode(): void { + $sessionName = session_name(); + $app = $this->statelessApplication(); $sessions = []; for ($i = 1; $i <= 3; $i++) { $sessionId = "worker-session-{$i}"; - $_COOKIE = ['PHPSESSID' => $sessionId]; + $_COOKIE = [$sessionName => $sessionId]; $_POST = ['data' => "user-{$i}-data"]; $_SERVER = [ 'REQUEST_METHOD' => 'POST', @@ -238,7 +240,7 @@ public function testMultipleRequestsWithDifferentSessionsInWorkerMode(): void } foreach ($sessions as $index => $sessionId) { - $_COOKIE = ['PHPSESSID' => $sessionId]; + $_COOKIE = [$sessionName => $sessionId]; $_POST = []; $_SERVER = [ 'REQUEST_METHOD' => 'GET', @@ -842,7 +844,7 @@ public function testSessionDataPersistenceWithSameSessionId(): void "'StatelessApplication'.", ); - $_COOKIE = ['PHPSESSID' => $sessionId]; + $_COOKIE = [$sessionName => $sessionId]; $_SERVER = [ 'REQUEST_METHOD' => 'GET', 'REQUEST_URI' => 'site/getsession', @@ -930,7 +932,7 @@ public function testSessionIsolationBetweenRequests(): void "'StatelessApplication'.", ); - $_COOKIE = ['PHPSESSID' => 'session-user-b']; + $_COOKIE = [$sessionName => 'session-user-b']; $_SERVER = [ 'REQUEST_METHOD' => 'GET', 'REQUEST_URI' => 'site/getsession', @@ -1115,7 +1117,7 @@ public function testUserAuthenticationSessionIsolation(): void ); // second user checks authentication status - should not be logged in - $_COOKIE = ['PHPSESSID' => 'user2-session']; + $_COOKIE = [$sessionName => 'user2-session']; $_POST = []; $_SERVER = [ 'REQUEST_METHOD' => 'GET', From e4d9b3796f0633615219e140f649862acc8dcabd Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 18:05:02 -0400 Subject: [PATCH 70/73] fix: Clarify comment to specify skipping the session cookie header in `StatelessApplicationTest` class. --- tests/http/StatelessApplicationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 43046653..7070528c 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -331,7 +331,7 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void $cookies = $response->getHeaders()['set-cookie'] ?? []; foreach ($cookies as $cookie) { - // skip the last cookie header (assumed to be 'PHPSESSION'). + // skip the session cookie header if (str_starts_with($cookie, $app->session->getName()) === false) { $params = explode('; ', $cookie); From 958fbca1a627cf4e7bc83db395365b8bdee6c632 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 18:07:19 -0400 Subject: [PATCH 71/73] fix: Adjust comment formatting to maintain consistency in `StatelessApplicationTest` class. --- tests/http/StatelessApplicationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 7070528c..25c23073 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -331,7 +331,7 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void $cookies = $response->getHeaders()['set-cookie'] ?? []; foreach ($cookies as $cookie) { - // skip the session cookie header + // skip the session cookie header if (str_starts_with($cookie, $app->session->getName()) === false) { $params = explode('; ', $cookie); From ea62a8f91b17796e563f41024207ebb1c954eb90 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 18:11:09 -0400 Subject: [PATCH 72/73] fix: Correct variable assignment in `testCaptchaSessionIsolation` method of `StatelessApplicationTest` class. --- tests/http/StatelessApplicationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 25c23073..8f3f620f 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -117,7 +117,7 @@ public function testCaptchaSessionIsolation(): void ); $hash1 = $captchaData1['hash1'] ?? null; - $hash2 = $captchaData2['hash2'] ?? null; + $hash2 = $captchaData2['hash1'] ?? null; self::assertNotNull( $hash1, From b6578e78ed3e08a0f55256bfbb317e966acdd314 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 29 Jul 2025 19:25:25 -0400 Subject: [PATCH 73/73] feat: Implement flash message handling in `SiteController` with `actionSetflash` and `actionGetflash` methods. --- tests/http/StatelessApplicationTest.php | 192 ++++++++++++++++++++++-- tests/support/stub/SiteController.php | 19 +++ 2 files changed, 195 insertions(+), 16 deletions(-) diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 8f3f620f..4a9b9c20 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -59,12 +59,24 @@ public function testCaptchaSessionIsolation(): void $response1 = $app->handle($request1); + self::assertSame( + 200, + $response1->getStatusCode(), + "Response status code should be '200' for 'site/captcha' route, confirming successful captcha generation " . + "in 'StatelessApplication'.", + ); self::assertSame( 'application/json; charset=UTF-8', $response1->getHeaders()['content-type'][0] ?? '', "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/captcha' route in " . "'StatelessApplication'.", ); + self::assertSame( + "{$sessionName}=user-a-session; Path=/; HttpOnly; SameSite", + $response1->getHeaders()['Set-Cookie'][0] ?? '', + "Response 'Set-Cookie' header should contain session 'ID' 'user-a-session' for 'site/captcha' route, " . + "ensuring correct session assignment in 'StatelessApplication'.", + ); $captchaData1 = Json::decode($response1->getBody()->getContents()); @@ -102,6 +114,25 @@ public function testCaptchaSessionIsolation(): void $response2 = $app->handle($request2); + self::assertSame( + 200, + $response2->getStatusCode(), + "Response status code should be '200' for 'site/captcha' route, confirming successful captcha generation " . + "for second user in 'StatelessApplication'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response2->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/captcha' route for " . + "second user in 'StatelessApplication', confirming correct content type for JSON captcha response.", + ); + self::assertSame( + "{$sessionName}=user-b-session; Path=/; HttpOnly; SameSite", + $response2->getHeaders()['Set-Cookie'][0] ?? '', + "Response 'Set-Cookie' header should contain session 'ID' 'user-b-session' for 'site/captcha' route, " . + "ensuring correct session assignment for second user in 'StatelessApplication'.", + ); + $captchaData2 = Json::decode($response2->getBody()->getContents()); self::assertIsArray( @@ -137,9 +168,9 @@ public function testCaptchaSessionIsolation(): void // also test that we can get the actual captcha image $url = $captchaData2['url'] ?? null; - self::assertNotNull( + self::assertIsString( $url, - "Captcha response 'url' should not be 'null' for second user in 'site/captcha' route in " . + "Captcha response 'url' should be a string for second user in 'site/captcha' route in " . "'StatelessApplication'.", ); @@ -153,30 +184,23 @@ public function testCaptchaSessionIsolation(): void $request3 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $response3 = $app->handle($request3); + $imageContent = $response3->getBody()->getContents(); - self::assertIsString( - $url, - "Captcha response 'url' should be a string for second user in 'site/captcha' route in " . - "'StatelessApplication'.", - ); self::assertSame( - 'image/png', - $response3->getHeaders()['content-type'][0] ?? '', - "Captcha image response 'content-type' should be 'image/png' for '{$url}' in 'StatelessApplication'.", + 200, + $response3->getStatusCode(), + "Response status code should be '200' for captcha image request for user-a-session in " . + "'StatelessApplication', confirming successful image retrieval and session isolation.", ); - - $imageContent = $response3->getBody()->getContents(); - self::assertNotEmpty( $imageContent, "Captcha image content should not be empty for '{$url}' in 'StatelessApplication'.", ); self::assertSame( 'image/png', - $response3->getHeaders()['content-type'][0], + $response3->getHeaders()['content-type'][0] ?? '', "Captcha image response 'content-type' should be 'image/png' for '{$url}' in 'StatelessApplication'.", ); - self::assertSame( "{$sessionName}=user-a-session; Path=/; HttpOnly; SameSite", $response3->getHeaders()['Set-Cookie'][0] ?? '', @@ -190,6 +214,92 @@ public function testCaptchaSessionIsolation(): void ); } + public function testFlashMessagesIsolationBetweenSessions(): void + { + $sessionName = session_name(); + + // first user sets a flash message + $_COOKIE = [$sessionName => 'flash-user-a']; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/setflash', + ]; + + $request1 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + + $response1 = $app->handle($request1); + $sessionName = $app->session->getName(); + + self::assertSame( + 200, + $response1->getStatusCode(), + "Response status code should be '200' for 'site/setflash' route, confirming successful flash message set " . + "in 'StatelessApplication'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response1->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/captcha' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + '{"status":"ok"}', + $response1->getBody()->getContents(), + "Response body should be '{\"status\":\"ok\"}' after setting flash message for 'site/setflash' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + "{$sessionName}=flash-user-a; Path=/; HttpOnly; SameSite", + $response1->getHeaders()['Set-Cookie'][0] ?? '', + "Response 'Set-Cookie' header should contain session 'ID' 'flash-user-a' for 'site/setflash' route, " . + "ensuring correct session assignment in 'StatelessApplication'.", + ); + + // second user checks for flash messages + $_COOKIE = [$sessionName => 'flash-user-b']; + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/getflash', + ]; + + $request2 = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $response2 = $app->handle($request2); + + $flashData = Json::decode($response2->getBody()->getContents()); + + self::assertSame( + 200, + $response2->getStatusCode(), + "Response status code should be '200' for 'site/getflash' route, confirming successful flash message " . + "retrieval in 'StatelessApplication'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response2->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/getflash' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + "{$sessionName}=flash-user-b; Path=/; HttpOnly; SameSite", + $response2->getHeaders()['Set-Cookie'][0] ?? '', + "Response 'Set-Cookie' header should contain session 'ID' 'flash-user-b' for 'site/getflash' route, " . + "ensuring correct session assignment in 'StatelessApplication'.", + ); + self::assertIsArray( + $flashData, + "Flash message response should be an array after decoding JSON for 'site/getflash' route in " . + "'StatelessApplication'.", + ); + self::assertEmpty( + $flashData['flash'] ?? [], + "Flash message array should be empty for new session 'flash-user-b', confirming session isolation in " . + "'StatelessApplication'.", + ); + } + /** * @throws InvalidConfigException */ @@ -234,7 +344,26 @@ public function testMultipleRequestsWithDifferentSessionsInWorkerMode(): void $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); - $app->handle($request); + $response = $app->handle($request); + + self::assertSame( + 200, + $response->getStatusCode(), + "Response status code should be '200' for 'site/setsessiondata' route in 'StatelessApplication', " . + 'confirming successful session data set in worker mode.', + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/setsessiondata' route " . + "in 'StatelessApplication', confirming correct content type for JSON session data response in worker mode.", + ); + self::assertSame( + "{$sessionName}={$sessionId}; Path=/; HttpOnly; SameSite", + $response->getHeaders()['Set-Cookie'][0] ?? '', + "Response 'Set-Cookie' header should contain session 'ID' '{$sessionId}' for 'site/setsessiondata' " . + "route in 'StatelessApplication', ensuring correct session assignment in worker mode.", + ); $sessions[] = $sessionId; } @@ -250,6 +379,37 @@ public function testMultipleRequestsWithDifferentSessionsInWorkerMode(): void $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); $response = $app->handle($request); + + self::assertSame( + 200, + $response->getStatusCode(), + sprintf( + "Response status code should be '200' for 'site/getsessiondata' route in 'StatelessApplication', " . + "confirming successful session data retrieval for session '%s' in worker mode.", + $sessionId, + ), + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response->getHeaders()['content-type'][0] ?? '', + sprintf( + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/getsessiondata' " . + "route in 'StatelessApplication', confirming correct content type for JSON session data response " . + "for session '%s' in worker mode.", + $sessionId, + ), + ); + self::assertSame( + "{$sessionName}={$sessionId}; Path=/; HttpOnly; SameSite", + $response->getHeaders()['Set-Cookie'][0] ?? '', + sprintf( + "Response 'Set-Cookie' header should contain session 'ID' '%s' for 'site/getsessiondata' route " . + "in 'StatelessApplication', ensuring correct session assignment for session '%s' in worker mode.", + $sessionId, + $sessionId, + ), + ); + $data = Json::decode($response->getBody()->getContents()); $expectedData = 'user-' . ($index + 1) . '-data'; diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index 42c5ca29..0adf9d3a 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -107,6 +107,16 @@ public function actionGetcookies(): array return $this->request->getCookies()->toArray(); } + /** + * @phpstan-return array{flash: mixed[]} + */ + public function actionGetflash(): array + { + $this->response->format = Response::FORMAT_JSON; + + return ['flash' => Yii::$app->session->getAllFlashes()]; + } + /** * @phpstan-return array */ @@ -193,6 +203,15 @@ public function actions(): array ]; } + public function actionSetflash(): void + { + $this->response->format = Response::FORMAT_JSON; + + Yii::$app->session->setFlash('success', 'Test flash message'); + + $this->response->data = ['status' => 'ok']; + } + public function actionSetsession(): void { $this->response->format = Response::FORMAT_JSON;