diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a1295cb3..a0c12f04 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, gd
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
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/composer.json b/composer.json
index a9f5cab1..df42e03b 100644
--- a/composer.json
+++ b/composer.json
@@ -12,8 +12,10 @@
"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"
},
"require-dev": {
@@ -28,6 +30,9 @@
"xepozz/internal-mocker": "^1.4",
"yii2-extensions/phpstan": "^0.3"
},
+ "suggest": {
+ "ext-uopz": "*"
+ },
"autoload": {
"psr-4": {
"yii2\\extensions\\psrbridge\\": "src"
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
diff --git a/src/http/ErrorHandler.php b/src/http/ErrorHandler.php
new file mode 100644
index 00000000..cf5fafdd
--- /dev/null
+++ b/src/http/ErrorHandler.php
@@ -0,0 +1,190 @@
+exception = $exception;
+
+ $this->unregister();
+
+ if (PHP_SAPI !== 'cli') {
+ $statusCode = 500;
+
+ if ($exception instanceof HttpException) {
+ $statusCode = $exception->statusCode;
+ }
+
+ http_response_code($statusCode);
+ }
+
+ 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;
+ }
+
+ /**
+ * 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();
+
+ $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) . '
';
+ $safeServerVars = array_diff_key(
+ $_SERVER,
+ array_flip(
+ [
+ 'API_KEY',
+ 'AUTH_TOKEN',
+ 'DB_PASSWORD',
+ 'SECRET_KEY',
+ ],
+ ),
+ );
+ $response->data .= "\n\$_SERVER = " . VarDumper::export($safeServerVars);
+ }
+
+ return $response;
+ }
+
+ /**
+ * 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
+ {
+ $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(self::convertExceptionToString($exception)) . '
';
+ } else {
+ if (YII_DEBUG) {
+ ini_set('display_errors', '1');
+ }
+
+ $file = $useErrorView ? $this->errorView : $this->exceptionView;
+
+ $response->data = $this->renderFile($file, ['exception' => $exception]);
+ }
+ } elseif ($response->format === Response::FORMAT_RAW) {
+ $response->data = self::convertExceptionToString($exception);
+ } else {
+ $response->data = $this->convertExceptionToArray($exception);
+ }
+
+ return $response;
+ }
+}
diff --git a/src/http/Request.php b/src/http/Request.php
index 74a51eb7..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.
@@ -43,6 +50,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.
*/
@@ -56,6 +69,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}]
+ */
+ $authToken = $this->getHeaders()->get('Authorization');
+
+ /** @phpstan-ignore-next-line */
+ 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') === false) {
+ return [null, null]; // return null for malformed credentials
+ }
+
+ $parts = explode(':', $decoded, 2);
+
+ if (count($parts) < 2) {
+ return [$parts[0] === '' ? null : $parts[0], null];
+ }
+
+ return [
+ $parts[0] === '' ? null : $parts[0],
+ (isset($parts[1]) && $parts[1] !== '') ? $parts[1] : null,
+ ];
+ }
+
+ return [null, null];
+ }
+
/**
* Retrieves the request body parameters, excluding the HTTP method override parameter if present.
*
diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php
new file mode 100644
index 00000000..d89bf7ef
--- /dev/null
+++ b/src/http/StatelessApplication.php
@@ -0,0 +1,497 @@
+
+ */
+ 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 = [];
+
+ /**
+ * Flag to indicate if memory limit should be recalculated.
+ */
+ private bool $shouldRecalculateMemoryLimit = false;
+
+ /**
+ * Creates a new instance of the {@see StatelessApplication} class.
+ *
+ * @phpstan-param array $config
+ *
+ * @phpstan-ignore constructor.missingParentCall
+ */
+ public function __construct(array $config = [])
+ {
+ $this->config = $config;
+
+ $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();
+
+ $limit = $this->getMemoryLimit();
+
+ $bound = (int) ($limit * 0.9);
+
+ $usage = memory_get_usage(true);
+
+ return $usage >= $bound;
+ }
+
+ /**
+ * 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.
+ *
+ * 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
+ {
+ return array_merge(
+ parent::coreComponents(),
+ [
+ 'errorHandler' => [
+ 'class' => ErrorHandler::class,
+ ],
+ 'request' => [
+ 'class' => Request::class,
+ ],
+ 'response' => [
+ 'class' => Response::class,
+ ],
+ ],
+ );
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 {
+ $this->reset($request);
+
+ $this->state = self::STATE_BEFORE_REQUEST;
+
+ $this->trigger(self::EVENT_BEFORE_REQUEST);
+
+ $this->state = self::STATE_HANDLING_REQUEST;
+
+ /** @phpstan-var Response $response */
+ $response = $this->handleRequest($this->request);
+
+ $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));
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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
+ {
+ 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;
+ }
+
+ /**
+ * 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));
+ }
+
+ $this->startEventTracking();
+
+ $config = $this->config;
+
+ if ($this->has('errorHandler')) {
+ $this->errorHandler->unregister();
+ }
+
+ // parent constructor is called because StatelessApplication uses a custom initialization pattern
+ // @phpstan-ignore-next-line
+ parent::__construct($config);
+
+ $this->requestedRoute = '';
+ $this->requestedAction = null;
+ $this->requestedParams = [];
+
+ $this->request->setPsr7Request($request);
+
+ $this->session->close();
+ $sessionId = $this->request->getCookies()->get($this->session->getName())->value ?? '';
+ $this->session->setId($sessionId);
+
+ // start the session with the correct 'ID'
+ $this->session->open();
+
+ $this->bootstrap();
+
+ $this->session->close();
+ }
+
+ /**
+ * 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();
+
+ UploadedFile::reset();
+
+ Yii::getLogger()->flush(true);
+
+ $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);
+
+ 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();
+ }
+
+ /**
+ * 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.
+ *
+ * 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 {
+ $this->registeredEvents[] = $event;
+ };
+ }
+
+ /**
+ * 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/TestCase.php b/tests/TestCase.php
index 65c3cd74..673678f7 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -4,9 +4,16 @@
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 yii2\extensions\psrbridge\tests\support\stub\Identity;
use function fclose;
use function tmpfile;
@@ -50,7 +57,12 @@ protected function tearDown(): void
protected function closeApplication(): void
{
if (Yii::$app->has('session')) {
- Yii::$app->getSession()->close();
+ $session = Yii::$app->getSession();
+
+ if ($session->getIsActive()) {
+ $session->destroy();
+ $session->close();
+ }
}
// ensure the logger is flushed after closing the application
@@ -88,6 +100,79 @@ 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/log/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',
+ ],
+ 'user' => [
+ 'enableAutoLogin' => false,
+ 'identityClass' => Identity::class,
+ ],
+ '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 +191,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/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\'.',
);
diff --git a/tests/creator/UploadedFileCreatorTest.php b/tests/creator/UploadedFileCreatorTest.php
index 2539ad8c..d9798ab2 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,41 @@ public function testCreateFromGlobalsWithSingleFile(): 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();
@@ -611,22 +648,31 @@ 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,
$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'.",
);
}
@@ -647,7 +693,7 @@ public function testThrowExceptionInBuildFileTreeRecursion(): void
],
'error' => [
'category' => [
- 'subcategory' => \UPLOAD_ERR_OK,
+ 'subcategory' => UPLOAD_ERR_OK,
],
],
],
@@ -680,7 +726,7 @@ public function testThrowExceptionInBuildFileTreeWithMismatchedArrayStructureErr
'level1' => [1024],
],
'error' => [
- 'level1' => \UPLOAD_ERR_OK,
+ 'level1' => UPLOAD_ERR_OK,
],
],
];
@@ -712,7 +758,7 @@ public function testThrowExceptionInBuildFileTreeWithMismatchedArrayStructureSiz
'level1' => 1024,
],
'error' => [
- 'level1' => [\UPLOAD_ERR_OK],
+ 'level1' => [UPLOAD_ERR_OK],
],
],
];
@@ -760,7 +806,7 @@ public function testThrowExceptionWhenMissingSize(): void
$fileSpec = [
'tmp_name' => $tmpPath,
- 'error' => \UPLOAD_ERR_OK,
+ 'error' => UPLOAD_ERR_OK,
];
$creator = new UploadedFileCreator(
@@ -781,7 +827,7 @@ public function testThrowExceptionWhenMissingTmpNameInFileSpec(): void
{
$fileSpec = [
'size' => 1024,
- 'error' => \UPLOAD_ERR_OK,
+ 'error' => UPLOAD_ERR_OK,
];
$creator = new UploadedFileCreator(
@@ -817,8 +863,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 +891,7 @@ public function testThrowExceptionWhenNameIsNotStringOrNull(): void
$fileSpec = [
'tmp_name' => $tmpPath,
'size' => 1024,
- 'error' => \UPLOAD_ERR_OK,
+ 'error' => UPLOAD_ERR_OK,
'name' => 123,
];
@@ -887,7 +933,7 @@ public function testThrowExceptionWhenSizeIsNotInteger(): void
$fileSpec = [
'tmp_name' => $tmpPath,
'size' => 'invalid',
- 'error' => \UPLOAD_ERR_OK,
+ 'error' => UPLOAD_ERR_OK,
];
$creator = new UploadedFileCreator(
@@ -909,7 +955,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 +976,7 @@ public function testThrowExceptionWhenTmpNameIsNotString(): void
$fileSpec = [
'tmp_name' => 123,
'size' => 1024,
- 'error' => \UPLOAD_ERR_OK,
+ 'error' => UPLOAD_ERR_OK,
];
$creator = new UploadedFileCreator(
@@ -984,7 +1030,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 +1061,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,
],
],
];
@@ -1032,6 +1078,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();
@@ -1065,7 +1131,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],
],
];
diff --git a/tests/http/ErrorHandlerTest.php b/tests/http/ErrorHandlerTest.php
new file mode 100644
index 00000000..2fb3beaf
--- /dev/null
+++ b/tests/http/ErrorHandlerTest.php
@@ -0,0 +1,393 @@
+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
new file mode 100644
index 00000000..4a9b9c20
--- /dev/null
+++ b/tests/http/StatelessApplicationTest.php
@@ -0,0 +1,1317 @@
+closeApplication();
+
+ parent::tearDown();
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function testCaptchaSessionIsolation(): void
+ {
+ $sessionName = session_name();
+
+ // first user generates captcha - need to use refresh=1 to get JSON response
+ $_COOKIE = [$sessionName => '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(
+ 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());
+
+ 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 = [$sessionName => '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);
+
+ 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(
+ $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['hash1'] ?? 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::assertIsString(
+ $url,
+ "Captcha response 'url' should be a string for second user in 'site/captcha' route in " .
+ "'StatelessApplication'.",
+ );
+
+ $_COOKIE = [$sessionName => 'user-a-session'];
+ $_GET = [];
+ $_SERVER = [
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => $url,
+ ];
+
+ $request3 = FactoryHelper::createServerRequestCreator()->createFromGlobals();
+
+ $response3 = $app->handle($request3);
+ $imageContent = $response3->getBody()->getContents();
+
+ self::assertSame(
+ 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.",
+ );
+ 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(
+ "{$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'.",
+ );
+ self::assertStringStartsWith(
+ "\x89PNG",
+ $imageContent,
+ "Captcha image content should start with PNG header for '{$url}' in 'StatelessApplication'.",
+ );
+ }
+
+ 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
+ */
+ public function testGetMemoryLimitHandlesUnlimitedMemoryCorrectly(): void
+ {
+ $originalLimit = ini_get('memory_limit');
+
+ ini_set('memory_limit', '-1');
+
+ $request = FactoryHelper::createServerRequestCreator()->createFromGlobals();
+
+ $app = $this->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();
+
+ ini_set('memory_limit', $originalLimit);
+ }
+
+ public function testMultipleRequestsWithDifferentSessionsInWorkerMode(): void
+ {
+ $sessionName = session_name();
+
+ $app = $this->statelessApplication();
+
+ $sessions = [];
+
+ for ($i = 1; $i <= 3; $i++) {
+ $sessionId = "worker-session-{$i}";
+ $_COOKIE = [$sessionName => $sessionId];
+ $_POST = ['data' => "user-{$i}-data"];
+ $_SERVER = [
+ 'REQUEST_METHOD' => 'POST',
+ 'REQUEST_URI' => 'site/setsessiondata',
+ ];
+
+ $request = FactoryHelper::createServerRequestCreator()->createFromGlobals();
+
+ $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;
+ }
+
+ foreach ($sessions as $index => $sessionId) {
+ $_COOKIE = [$sessionName => $sessionId];
+ $_POST = [];
+ $_SERVER = [
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => 'site/getsessiondata',
+ ];
+
+ $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';
+
+ 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');
+
+ 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);
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function testReturnCookiesHeadersForSiteCookieRoute(): void
+ {
+ $_SERVER = [
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => 'site/cookie',
+ ];
+
+ $request = FactoryHelper::createServerRequestCreator()->createFromGlobals();
+
+ $app = $this->statelessApplication();
+
+ $response = $app->handle($request);
+
+ 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 $cookie) {
+ // skip the session cookie header
+ if (str_starts_with($cookie, $app->session->getName()) === false) {
+ $params = explode('; ', $cookie);
+
+ 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.',
+ $params[0],
+ ),
+ );
+ }
+ }
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ 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'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ 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);
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ 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::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'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ 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::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'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ 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::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'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ 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::assertSame(
+ 200,
+ $response->getStatusCode(),
+ "Response status code should be '200' for 'site/post' route in 'StatelessApplication'.",
+ );
+ self::assertSame(
+ <<getBody()->getContents(),
+ "Response body should match expected JSON string '{\"foo\":\"bar\",\"a\":{\"b\":\"c\"}}' for 'site/post'" .
+ "route in 'StatelessApplication'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ 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::assertSame(
+ 200,
+ $response->getStatusCode(),
+ "Response status code should be '200' for 'site/get' route in 'StatelessApplication'.",
+ );
+ self::assertSame(
+ <<getBody()->getContents(),
+ "Response body should match expected JSON string '{\"foo\":\"bar\",\"a\":{\"b\":\"c\"}}' for 'site/get' " .
+ "route in 'StatelessApplication'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ 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);
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function testReturnPlainTextFileResponseForSiteFileRoute(): void
+ {
+ $_SERVER = [
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => 'site/file',
+ ];
+
+ $request = FactoryHelper::createServerRequestCreator()->createFromGlobals();
+
+ $response = $this->statelessApplication()->handle($request);
+
+ 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'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function testReturnPlainTextResponseWithFileContentForSiteStreamRoute(): void
+ {
+ $_SERVER = [
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => 'site/stream',
+ ];
+
+ $request = FactoryHelper::createServerRequestCreator()->createFromGlobals();
+
+ $response = $this->statelessApplication()->handle($request);
+
+ 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'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function testReturnRedirectResponseForSiteRedirectRoute(): void
+ {
+ $_SERVER = [
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => 'site/redirect',
+ ];
+
+ $request = FactoryHelper::createServerRequestCreator()->createFromGlobals();
+
+ $response = $this->statelessApplication()->handle($request);
+
+ 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'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function testReturnRedirectResponseForSiteRefreshRoute(): void
+ {
+ $_SERVER = [
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => 'site/refresh',
+ ];
+
+ $request = FactoryHelper::createServerRequestCreator()->createFromGlobals();
+
+ $response = $this->statelessApplication()->handle($request);
+
+ 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'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function testReturnsJsonResponse(): void
+ {
+ $request = FactoryHelper::createServerRequestCreator()->createFromGlobals();
+
+ $response = $this->statelessApplication()->handle($request);
+
+ 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(
+ << 'GET',
+ 'REQUEST_URI' => 'site/statuscode',
+ ];
+
+ $request = FactoryHelper::createServerRequestCreator()->createFromGlobals();
+
+ $response = $this->statelessApplication()->handle($request);
+
+ self::assertSame(
+ 201,
+ $response->getStatusCode(),
+ "Response status code should be '201' for 'site/statuscode' route in 'StatelessApplication'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function testSessionDataPersistenceWithSameSessionId(): void
+ {
+ $sessionName = session_name();
+ $sessionId = 'test-session-' . uniqid();
+
+ $_COOKIE = [$sessionName => $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(
+ "{$sessionName}={$sessionId}; Path=/; HttpOnly; SameSite",
+ $response1->getHeaders()['Set-Cookie'][0] ?? '',
+ "Response 'Set-Cookie' header should contain '{$sessionId}' for 'site/setsession' route in " .
+ "'StatelessApplication'.",
+ );
+
+ $_COOKIE = [$sessionName => $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(
+ "{$sessionName}={$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());
+
+ 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',
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function testSessionIsolationBetweenRequests(): void
+ {
+ $sessionName = session_name();
+
+ $_COOKIE = [$sessionName => 'session-user-a'];
+ $_SERVER = [
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => 'site/setsession',
+ ];
+
+ // first request - set a session value
+ $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(
+ "{$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'.",
+ );
+
+ $_COOKIE = [$sessionName => 'session-user-b'];
+ $_SERVER = [
+ 'REQUEST_METHOD' => 'GET',
+ 'REQUEST_URI' => 'site/getsession',
+ ];
+
+ // second request - different session
+ $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(
+ "{$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'.",
+ );
+
+ $body = Json::decode($response2->getBody()->getContents());
+
+ 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'.",
+ );
+ }
+
+ /**
+ * @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'] ?? [];
+ $sessionName = $app->session->getName();
+ $cookie = array_filter(
+ $cookies,
+ static fn(string $cookie): bool => str_starts_with($cookie, "{$sessionName}="),
+ );
+
+ self::assertCount(
+ 1,
+ $cookie,
+ "Response 'Set-Cookie' header should contain exactly one '{$sessionName}' cookie when no session cookie " .
+ "is sent in 'StatelessApplication'.",
+ );
+ self::assertMatchesRegularExpression(
+ '/^' . 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] ?? '') . "'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ 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'.",
+ );
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ #[DataProviderExternal(StatelessApplicationProvider::class, 'eventDataProvider')]
+ public function testTriggerEventDuringHandle(string $eventName): void
+ {
+ $eventTriggered = false;
+
+ $request = FactoryHelper::createServerRequestCreator()->createFromGlobals();
+
+ $app = $this->statelessApplication();
+
+ $app->on(
+ $eventName,
+ static function () use (&$eventTriggered): void {
+ $eventTriggered = true;
+ },
+ );
+
+ $app->handle($request);
+
+ self::assertTrue($eventTriggered, "Should trigger '{$eventName}' event during handle()");
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function testUserAuthenticationSessionIsolation(): void
+ {
+ $sessionName = session_name();
+
+ // first user logs in
+ $_COOKIE = [$sessionName => '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(
+ "{$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'.",
+ );
+ 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 = [$sessionName => '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(
+ "{$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'.",
+ );
+ 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 23ce8913..5bf89a41 100644
--- a/tests/phpstan-config.php
+++ b/tests/phpstan-config.php
@@ -4,8 +4,24 @@
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' => [
+ 'errorHandler' => [
+ 'class' => ErrorHandler::class,
+ ],
+ 'request' => [
+ 'class' => Request::class,
+ ],
+ 'response' => [
+ 'class' => Response::class,
+ ],
+ 'user' => [
+ 'identityClass' => Identity::class,
+ ],
+ ],
'container' => [
'definitions' => [
ResponseFactoryInterface::class => ResponseFactory::class,
diff --git a/tests/provider/RequestProvider.php b/tests/provider/RequestProvider.php
index 7bb6003c..626e1a7c 100644
--- a/tests/provider/RequestProvider.php
+++ b/tests/provider/RequestProvider.php
@@ -4,8 +4,6 @@
namespace yii2\extensions\psrbridge\tests\provider;
-use function base64_decode;
-
final class RequestProvider
{
/**
@@ -469,7 +467,7 @@ public static function httpAuthorizationHeaders(): array
[
'not a base64 at all',
[
- base64_decode('not a base64 at all', true),
+ null,
null,
],
],
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],
+ ];
+ }
+}
diff --git a/tests/support/FactoryHelper.php b/tests/support/FactoryHelper.php
index c01cb3ac..57c29572 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(
+ self::createServerRequestFactory(),
+ self::createStreamFactory(),
+ self::createUploadedFileFactory(),
+ );
+ }
+
/**
* Creates a PSR-17 {@see ServerRequestFactory} instance.
*
diff --git a/tests/support/stub/Identity.php b/tests/support/stub/Identity.php
new file mode 100644
index 00000000..e2795418
--- /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): self|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
new file mode 100644
index 00000000..0adf9d3a
--- /dev/null
+++ b/tests/support/stub/SiteController.php
@@ -0,0 +1,259 @@
+response->format = Response::FORMAT_JSON;
+
+ return [
+ 'username' => $this->request->getAuthUser(),
+ 'password' => $this->request->getAuthPassword(),
+ ];
+ }
+
+ /**
+ * @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(
+ new Cookie(
+ [
+ 'name' => 'test',
+ 'value' => 'test',
+ 'httpOnly' => false,
+ ],
+ ),
+ );
+
+ $this->response->cookies->add(
+ new Cookie(
+ [
+ 'name' => 'test2',
+ 'value' => 'test2',
+ ],
+ ),
+ );
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function actionFile(): Response
+ {
+ $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 actionGet(): mixed
+ {
+ $this->response->format = Response::FORMAT_JSON;
+
+ return $this->request->get();
+ }
+
+ /**
+ * @phpstan-return Cookie[]
+ */
+ public function actionGetcookies(): array
+ {
+ $this->response->format = Response::FORMAT_JSON;
+
+ 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
+ */
+ public function actionGetsession(): array
+ {
+ $this->response->format = Response::FORMAT_JSON;
+
+ 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[]
+ */
+ public function actionIndex(): array
+ {
+ $this->response->format = Response::FORMAT_JSON;
+
+ 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 === null || $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;
+
+ return $this->request->post();
+ }
+
+ /**
+ * @throws InvalidRouteException
+ */
+ public function actionRedirect(): void
+ {
+ $this->response->redirect('/site/index');
+ }
+
+ public function actionRefresh(): void
+ {
+ $this->response->refresh('#stateless');
+ }
+
+ public function actions(): array
+ {
+ return [
+ 'captcha' => [
+ 'class' => CaptchaAction::class,
+ 'minLength' => 4,
+ 'maxLength' => 6,
+ ],
+ ];
+ }
+
+ 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;
+
+ Yii::$app->session->set('testValue', 'test-value');
+
+ $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;
+ }
+
+ /**
+ * @throws Exception
+ * @throws RangeNotSatisfiableHttpException
+ */
+ public function actionStream(): Response
+ {
+ $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);
+
+ return $this->response->sendStreamAsFile($tmpFile, 'stream.txt', ['mimeType' => 'text/plain']);
+ }
+}