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']); + } +}