From 54897bc674e9744a7c6cb6ec509971529d49e7d2 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 19 Jul 2025 14:08:51 -0400 Subject: [PATCH 01/32] Introduce `Request` bridge implementation with tests. --- phpstan.neon | 3 + phpunit.xml.dist | 2 +- src/adapter/ServerRequestAdapter.php | 334 ++++++ src/emitter/SapiEmitter.php | 2 +- src/http/Request.php | 162 +++ tests/TestCase.php | 94 ++ tests/bootstrap.php | 15 + tests/http/RequestTest.php | 1481 ++++++++++++++++++++++++++ tests/provider/RequestProvider.php | 879 +++++++++++++++ 9 files changed, 2970 insertions(+), 2 deletions(-) create mode 100644 src/adapter/ServerRequestAdapter.php create mode 100644 src/http/Request.php create mode 100644 tests/TestCase.php create mode 100644 tests/bootstrap.php create mode 100644 tests/http/RequestTest.php create mode 100644 tests/provider/RequestProvider.php diff --git a/phpstan.neon b/phpstan.neon index 63c0f318..735031cc 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,9 @@ includes: - phar://phpstan.phar/conf/bleedingEdge.neon parameters: + bootstrapFiles: + - tests/bootstrap.php + level: max paths: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4e7b60ef..87ad9924 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,7 @@ |object + */ + public function getBodyParams(string $methodParam): array|object + { + $parsedBody = $this->psrRequest->getParsedBody(); + + // Remove method parameter if present (same logic as parent) + if (is_array($parsedBody) && isset($parsedBody[$methodParam])) { + $bodyParams = $parsedBody; + + unset($bodyParams[$methodParam]); + + return $bodyParams; + } + + return $parsedBody ?? []; + } + + /** + * @phpstan-return array + */ + public function getCookieParams(): array + { + // @phpstan-ignore return.type + return $this->psrRequest->getCookieParams(); + } + + /** + * @phpstan-return array + */ + public function getCookies(bool $enableValidation = false, string $validationKey = ''): array + { + $cookies = []; + $cookieParams = $this->psrRequest->getCookieParams(); + + if ($enableValidation) { + if ($validationKey === '') { + throw new InvalidConfigException('Cookie validation key must be provided.'); + } + + foreach ($cookieParams as $name => $value) { + if (is_string($value) && $value !== '') { + $data = Yii::$app->getSecurity()->validateData($value, $validationKey); + + if (is_string($data) === false) { + continue; + } + + $data = @unserialize($data); + + if (is_array($data) && isset($data[0], $data[1]) && $data[0] === $name) { + $cookies[$name] = new Cookie( + [ + 'name' => $name, + 'value' => $data[1], + 'expire' => null, + ], + ); + } + } + } + } else { + foreach ($cookieParams as $name => $value) { + if ($value === '') { + continue; + } + + $cookies[$name] = new Cookie( + [ + 'name' => $name, + 'value' => $value, + 'expire' => null, + ], + ); + } + } + + return $cookies; + } + + public function getHeaders(): HeaderCollection + { + $headerCollection = new HeaderCollection(); + + foreach ($this->psrRequest->getHeaders() as $name => $values) { + $headerCollection->set((string) $name, implode(', ', $values)); + } + + return $headerCollection; + } + + public function getMethod(): string + { + return $this->psrRequest->getMethod(); + } + + public function getMethodWithOverride(string $methodParam = '_method'): string + { + $parsedBody = $this->psrRequest->getParsedBody(); + + // Check for method override in body + if ( + is_array($parsedBody) && + isset($parsedBody[$methodParam]) && + is_string($parsedBody[$methodParam]) + ) { + $methodOverride = strtoupper($parsedBody[$methodParam]); + + if (in_array($methodOverride, ['GET', 'HEAD', 'OPTIONS'], true) === false) { + return $methodOverride; + } + } + + // Check for X-Http-Method-Override header + $headers = $this->psrRequest->getHeaders(); + + if (isset($headers['X-Http-Method-Override']) && isset($headers['X-Http-Method-Override'][0])) { + return strtoupper($headers['X-Http-Method-Override'][0]); + } + + return $this->psrRequest->getMethod(); + } + + /** + * @phpstan-return array|object|null + */ + public function getParsedBody(): array|object|null + { + // @phpstan-ignore return.type + return $this->psrRequest->getParsedBody(); + } + + /** + * @phpstan-return array + */ + public function getQueryParams(): array + { + // @phpstan-ignore return.type + return $this->psrRequest->getQueryParams(); + } + + public function getQueryString(): string + { + return $this->psrRequest->getUri()->getQuery(); + } + + public function getRawBody(): string + { + $body = $this->psrRequest->getBody(); + $body->rewind(); + + return $body->getContents(); + } + + public function getScriptUrl(): string + { + $serverParams = $this->psrRequest->getServerParams(); + + // for traditional PSR-7 apps where SCRIPT_NAME is available + if (isset($serverParams['SCRIPT_NAME']) && is_string($serverParams['SCRIPT_NAME'])) { + return $serverParams['SCRIPT_NAME']; + } + + // for PSR-7 workers (RoadRunner, Franken, etc.) where no script file exists + // return empty to prevent URL duplication as routing is handled internally + return ''; + } + + /** + * @phpstan-return array + */ + public function getServerParams(): array + { + // @phpstan-ignore return.type + return $this->psrRequest->getServerParams(); + } + + /** + * @phpstan-return array< + * string, + * array{ + * name: string|array, + * type: string|array, + * tmp_name: string|array, + * error: int|array, + * size: int|array, + * } + * > + */ + public function getUploadedFiles(): array + { + /** @phpstan-var array> $uploadedFiles */ + $uploadedFiles = $this->psrRequest->getUploadedFiles(); + return $this->normalizeUploadedFiles($uploadedFiles); + } + + public function getUrl(): string + { + $uri = $this->psrRequest->getUri(); + $url = $uri->getPath(); + + if ($uri->getQuery() !== '') { + $url .= '?' . $uri->getQuery(); + } + + return $url; + } + + /** + * @phpstan-return array{name: string, type: string, tmp_name: string, error: int, size: int} + */ + private function convertSingleFile(UploadedFileInterface $file): array + { + $stream = $file->getStream(); + $streamMeta = $stream->getMetadata(); + $tempName = ''; + + if (is_array($streamMeta) && isset($streamMeta['uri']) && is_string($streamMeta['uri'])) { + $tempName = $streamMeta['uri']; + + // For in-memory streams, create a temporary file + if (str_starts_with($tempName, 'php://')) { + $tempFile = tempnam(sys_get_temp_dir(), 'upload'); + if ($tempFile !== false) { + $stream->rewind(); + file_put_contents($tempFile, $stream->getContents()); + $tempName = $tempFile; + } + } + } + + return [ + 'name' => $file->getClientFilename() ?? '', + 'type' => $file->getClientMediaType() ?? '', + 'tmp_name' => $tempName, + 'error' => $file->getError(), + 'size' => $file->getSize() ?? 0, + ]; + } + + /** + * @phpstan-param array $fileArray + * + * @phpstan-return array{ + * name: array, + * type: array, + * tmp_name: array, + * error: array, + * size: array, + * } + */ + private function normalizeFileArray(array $fileArray): array + { + $names = []; + $types = []; + $tmpNames = []; + $errors = []; + $sizes = []; + + foreach ($fileArray as $key => $file) { + if ($file instanceof UploadedFileInterface) { + $converted = $this->convertSingleFile($file); + $names[$key] = $converted['name']; + $types[$key] = $converted['type']; + $tmpNames[$key] = $converted['tmp_name']; + $errors[$key] = $converted['error']; + $sizes[$key] = $converted['size']; + } elseif (is_array($file)) { + // Nested array - recursively normalize + $nestedNormalized = $this->normalizeFileArray($file); + $names[$key] = $nestedNormalized['name']; + $types[$key] = $nestedNormalized['type']; + $tmpNames[$key] = $nestedNormalized['tmp_name']; + $errors[$key] = $nestedNormalized['error']; + $sizes[$key] = $nestedNormalized['size']; + } + } + + return [ + 'name' => $names, + 'type' => $types, + 'tmp_name' => $tmpNames, + 'error' => $errors, + 'size' => $sizes, + ]; + } + + /** + * @phpstan-param array> $uploadedFiles + * + * @phpstan-return array< + * string, + * array{ + * name: string|array, + * type: string|array, + * tmp_name: string|array, + * error: int|array, + * size: int|array, + * } + * > + */ + private function normalizeUploadedFiles(array $uploadedFiles): array + { + $normalized = []; + + foreach ($uploadedFiles as $fieldName => $fileData) { + if ($fileData instanceof UploadedFileInterface) { + // Single file + $normalized[$fieldName] = $this->convertSingleFile($fileData); + } elseif (is_array($fileData)) { + // Multiple files or nested structure + $normalized[$fieldName] = $this->normalizeFileArray($fileData); + } + } + + return $normalized; + } +} diff --git a/src/emitter/SapiEmitter.php b/src/emitter/SapiEmitter.php index c13efd77..1619ea08 100644 --- a/src/emitter/SapiEmitter.php +++ b/src/emitter/SapiEmitter.php @@ -170,7 +170,7 @@ private function emitBodyRange(StreamInterface $body, int $first, int $last): vo private function emitHeaders(ResponseInterface $response): void { foreach ($response->getHeaders() as $name => $values) { - $name = str_replace(' ', '-', ucwords(str_replace('-', ' ', strtolower((string) $name)), ' -')); + $name = str_replace(' ', '-', ucwords(strtolower(str_replace('-', ' ', (String) $name)))); match ($name) { 'Set-Cookie' => array_map( diff --git a/src/http/Request.php b/src/http/Request.php new file mode 100644 index 00000000..45d0d99b --- /dev/null +++ b/src/http/Request.php @@ -0,0 +1,162 @@ +|object + */ + public function getBodyParams(): array|object + { + if ($this->adapter !== null) { + return $this->adapter->getBodyParams($this->methodParam); + } + + return parent::getBodyParams(); + } + + public function getCookies() + { + if ($this->_cookies === null) { + $cookies = $this->adapter !== null + ? $this->adapter->getCookies($this->enableCookieValidation, $this->cookieValidationKey) + : parent::loadCookies(); + + $this->_cookies = new CookieCollection($cookies, ['readOnly' => true]); + } + + return $this->_cookies; + } + + public function getCsrfTokenFromHeader(): string|null + { + if ($this->adapter !== null) { + return $this->getHeaders()->get($this->csrfHeader); + } + + return parent::getCsrfTokenFromHeader(); + } + + public function getHeaders(): HeaderCollection + { + if ($this->adapter !== null) { + $headers = $this->adapter->getHeaders(); + + $this->filterHeaders($headers); + + return $headers; + } + + return parent::getHeaders(); + } + + public function getMethod(): string + { + if ($this->adapter !== null) { + return $this->adapter->getMethodWithOverride($this->methodParam); + } + + return parent::getMethod(); + } + + public function getParsedBody(): mixed + { + return $this->getAdapter()->getParsedBody(); + } + + public function getPsr7Request(): ServerRequestInterface + { + return $this->getAdapter()->psrRequest; + } + + /** + * @phpstan-return array + */ + public function getQueryParams(): array + { + if ($this->adapter !== null) { + return $this->adapter->getQueryParams(); + } + + // @phpstan-ignore return.type + return parent::getQueryParams(); + } + + public function getQueryString() + { + if ($this->adapter !== null) { + return $this->adapter->getQueryString(); + } + + return parent::getQueryString(); + } + + public function getRawBody(): string + { + if ($this->adapter !== null) { + return $this->adapter->getRawBody(); + } + + return parent::getRawBody(); + } + + public function getScriptUrl(): string + { + if ($this->adapter !== null) { + return $this->adapter->getScriptUrl(); + } + + return parent::getScriptUrl(); + } + + /** + * @return array> + */ + public function getUploadedFiles(): array + { + // @phpstan-ignore return.type + return $this->getPsr7Request()->getUploadedFiles(); + } + + public function getUrl(): string + { + return $this->getAdapter()->getUrl(); + } + + /** + * Reset para workers + */ + public function reset(): void + { + $this->adapter = null; + $this->_cookies = null; + } + + /** + * Establece la request PSR-7 + */ + public function setPsr7Request(ServerRequestInterface $request): void + { + $this->adapter = new ServerRequestAdapter($request); + } + + private function getAdapter(): ServerRequestAdapter + { + if ($this->adapter === null) { + throw new InvalidConfigException('PSR-7 request adapter is not set.'); + } + + return $this->adapter; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..9b4c6b32 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,94 @@ + + */ + private array $originalServer = []; + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + + $logger = Yii::getLogger(); + $logger->flush(); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->originalServer = $_SERVER; + $_SERVER = []; + } + + protected function tearDown(): void + { + $_SERVER = $this->originalServer; + $_POST = []; + $_COOKIE = []; + + parent::tearDown(); + } + + /** + * @phpstan-param array $config + */ + protected function mockApplication($config = []): void + { + new \yii\console\Application( + ArrayHelper::merge( + [ + 'id' => 'testapp', + 'basePath' => __DIR__, + 'vendorPath' => dirname(__DIR__) . '/vendor', + 'components' => [ + 'request' => [ + 'class' => Request::class, + ], + ], + ], + $config, + ), + ); + } + + /** + * @phpstan-param array $config + */ + protected function mockWebApplication($config = []): void + { + new \yii\web\Application( + ArrayHelper::merge( + [ + 'id' => 'testapp', + 'basePath' => __DIR__, + 'vendorPath' => dirname(__DIR__) . '/vendor', + 'aliases' => [ + '@bower' => '@vendor/bower-asset', + '@npm' => '@vendor/npm-asset', + ], + 'components' => [ + 'request' => [ + 'class' => Request::class, + 'cookieValidationKey' => 'wefJDF8sfdsfSDefwqdxj9oq', + 'scriptFile' => __DIR__ . '/index.php', + 'scriptUrl' => '/index.php', + 'isConsoleRequest' => false, + ], + ], + ], + $config, + ), + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..2cf23aa2 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,15 @@ + $trustedHosts, + 'ipHeaders' => [], + ], + ); + + self::assertSame( + $expectedRemoteAddress, + $request->remoteIP, + 'Remote IP fail!.', + ); + self::assertSame( + $expectedUserIp, + $request->userIP, + 'User IP fail!.', + ); + self::assertSame( + $expectedIsSecureConnection, + $request->isSecureConnection, + 'Secure connection fail!.', + ); + } + + public function testCsrfHeaderValidation(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $request->validateCsrfHeaderOnly = true; + $request->enableCsrfValidation = true; + + // only accept valid header on unsafe requests + foreach (['GET', 'HEAD', 'POST'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + + $request->headers->remove(Request::CSRF_HEADER); + + self::assertFalse( + $request->validateCsrfToken(), + "'CSRF' token validation should fail when the 'CSRF' header is missing for unsafe 'HTTP' methods.", + ); + + $request->headers->add(Request::CSRF_HEADER, ''); + + self::assertTrue( + $request->validateCsrfToken(), + "'CSRF' token validation should pass when the 'CSRF' header is present for unsafe 'HTTP' methods.", + ); + } + + // accept no value on other requests + foreach (['DELETE', 'PATCH', 'PUT', 'OPTIONS'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + + self::assertTrue( + $request->validateCsrfToken(), + "'CSRF' token validation should pass for safe 'HTTP' methods regardless of 'CSRF' header.", + ); + } + } + + /** + * @see https://github.com/yiisoft/yii2/issues/14542 + */ + public function testCsrfTokenContainsASCIIOnly(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $request->enableCsrfCookie = false; + + $token = $request->getCsrfToken(); + + self::assertNotNull( + $token, + "'CSRF' token should not be null after generation.", + ); + self::assertMatchesRegularExpression( + '~[-_=a-z0-9]~i', + $token, + "'CSRF' token should only contain ASCII characters ('a-z', '0-9', '-', '_', '=').", + ); + } + + /** + * Test CSRF token validation by POST param. + */ + public function testCsrfTokenHeader(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $request->enableCsrfCookie = false; + + $token = $request->getCsrfToken(); + + // accept no value on GET request + foreach (['GET', 'HEAD', 'OPTIONS'] as $method) { + $_POST[$request->methodParam] = $method; + + self::assertTrue( + $request->validateCsrfToken(), + "'CSRF' token validation should pass for safe 'HTTP' methods ('GET', 'HEAD', 'OPTIONS') even " . + 'if no token is provided.', + ); + } + + // only accept valid token on POST + foreach (['POST', 'PUT', 'DELETE'] as $method) { + $_POST[$request->methodParam] = $method; + + $request->setBodyParams([]); + $request->headers->remove(Request::CSRF_HEADER); + + self::assertFalse( + $request->validateCsrfToken(), + "'CSRF' token validation should fail for unsafe 'HTTP' methods ('POST', 'PUT', 'DELETE') if no " . + 'token is provided.', + ); + self::assertNotNull( + $token, + "'CSRF' token should not be 'null' after generation.", + ); + + $request->headers->add(Request::CSRF_HEADER, $token); + + self::assertTrue( + $request->validateCsrfToken(), + "'CSRF' token validation should pass for unsafe 'HTTP' methods ('POST', 'PUT', 'DELETE') if a " . + 'valid token is provided in the header.', + ); + } + } + + /** + * Test CSRF token validation by POST param. + */ + public function testCsrfTokenPost(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $request->enableCsrfCookie = false; + + $token = $request->getCsrfToken(); + + // accept no value on GET request + foreach (['GET', 'HEAD', 'OPTIONS'] as $method) { + $_POST[$request->methodParam] = $method; + + self::assertTrue( + $request->validateCsrfToken(), + "'CSRF' token validation should pass for safe 'HTTP' methods ('GET', 'HEAD', 'OPTIONS') even if no " . + "token is provided in 'POST' params.", + ); + } + + // only accept valid token on POST + foreach (['POST', 'PUT', 'DELETE'] as $method) { + $_POST[$request->methodParam] = $method; + + $request->setBodyParams([]); + + self::assertFalse( + $request->validateCsrfToken(), + "'CSRF' token validation should fail for unsafe 'HTTP' methods ('POST', 'PUT', 'DELETE') if no " . + "token is provided in 'POST' params.", + ); + + $request->setBodyParams([$request->csrfParam => $token]); + + self::assertTrue( + $request->validateCsrfToken(), + "'CSRF' token validation should pass for unsafe 'HTTP' methods ('POST', 'PUT', 'DELETE') if a " . + "valid token is provided in 'POST' params.", + ); + } + } + + public function testCsrfTokenValidation(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $request->enableCsrfCookie = false; + + $token = $request->getCsrfToken(); + + // accept any value if CSRF validation is disabled + $request->enableCsrfValidation = false; + + self::assertTrue( + $request->validateCsrfToken($token), + "'CSRF' token validation should pass for any value if 'CSRF' validation is disabled.", + ); + self::assertTrue( + $request->validateCsrfToken($token . 'a'), + "'CSRF' token validation should pass for any value if 'CSRF' validation is disabled.", + ); + self::assertTrue( + $request->validateCsrfToken(null), + "'CSRF' token validation should pass for 'null' value if 'CSRF' validation is disabled.", + ); + + // enable validation + $request->enableCsrfValidation = true; + + // accept any value on GET request + foreach (['GET', 'HEAD', 'OPTIONS'] as $method) { + $_POST[$request->methodParam] = $method; + + self::assertTrue( + $request->validateCsrfToken($token), + "'CSRF' token validation should pass for valid token on safe 'HTTP' methods ('GET', 'HEAD', 'OPTIONS').", + ); + self::assertTrue( + $request->validateCsrfToken($token . 'a'), + "'CSRF' token validation should pass for any value on safe 'HTTP' methods ('GET', 'HEAD', 'OPTIONS').", + ); + self::assertTrue( + $request->validateCsrfToken(null), + "'CSRF' token validation should pass for 'null' value on safe 'HTTP' methods ('GET', 'HEAD', 'OPTIONS').", + ); + } + + // only accept valid token on POST + foreach (['POST', 'PUT', 'DELETE'] as $method) { + $_POST[$request->methodParam] = $method; + + self::assertTrue( + $request->validateCsrfToken($token), + "'CSRF' token validation should pass for valid token on unsafe 'HTTP' methods ('POST', 'PUT', 'DELETE').", + ); + self::assertFalse( + $request->validateCsrfToken($token . 'a'), + "'CSRF' token validation should fail for invalid token on unsafe 'HTTP' methods ('POST', 'PUT', 'DELETE').", + ); + self::assertFalse( + $request->validateCsrfToken(null), + "'CSRF' token validation should fail for 'null' value on unsafe 'HTTP' methods ('POST', 'PUT', 'DELETE').", + ); + } + } + + public function testCustomHeaderCsrfHeaderValidation(): void + { + $this->mockWebApplication(); + + $request = new Request(); + $request->csrfHeader = 'X-JGURDA'; + $request->validateCsrfHeaderOnly = true; + $request->enableCsrfValidation = true; + + // only accept valid header on unsafe requests + foreach (['GET', 'HEAD', 'POST'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + + $request->headers->remove('X-JGURDA'); + + self::assertFalse( + $request->validateCsrfToken(), + "'CSRF' token validation should fail when the custom 'CSRF' header is missing for unsafe 'HTTP' methods.", + ); + + $request->headers->add('X-JGURDA', ''); + + self::assertTrue( + $request->validateCsrfToken(), + "'CSRF' token validation should pass when the custom 'CSRF' header is present for unsafe 'HTTP' methods.", + ); + } + } + + public function testCustomSafeMethodsCsrfTokenValidation(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $request->csrfTokenSafeMethods = ['OPTIONS']; + $request->enableCsrfCookie = false; + $request->enableCsrfValidation = true; + + $token = $request->getCsrfToken(); + + // accept any value on custom safe request + foreach (['OPTIONS'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + + self::assertTrue( + $request->validateCsrfToken($token), + "'CSRF' token validation should pass for valid token on custom safe 'HTTP' methods ('OPTIONS').", + ); + self::assertTrue( + $request->validateCsrfToken($token . 'a'), + "'CSRF' token validation should pass for any value on custom safe 'HTTP' methods ('OPTIONS').", + ); + self::assertTrue( + $request->validateCsrfToken(null), + "'CSRF' token validation should pass for 'null' value on custom safe 'HTTP' methods ('OPTIONS').", + ); + self::assertTrue( + $request->validateCsrfToken(), + "'CSRF' token validation should pass when no token is provided on custom safe 'HTTP' methods ('OPTIONS').", + ); + } + + // only accept valid token on other requests + foreach (['GET', 'HEAD', 'POST'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + + self::assertTrue( + $request->validateCsrfToken($token), + "'CSRF' token validation should pass for valid token on 'HTTP' methods ('GET', 'HEAD', 'POST').", + ); + self::assertFalse( + $request->validateCsrfToken($token . 'a'), + "'CSRF' token validation should fail for invalid token on 'HTTP' methods ('GET', 'HEAD', 'POST').", + ); + self::assertFalse( + $request->validateCsrfToken(null), + "'CSRF' token validation should fail for 'null' value on 'HTTP' methods ('GET', 'HEAD', 'POST').", + ); + self::assertFalse( + $request->validateCsrfToken(), + "'CSRF' token validation should fail when no token is provided on 'HTTP' methods ('GET', 'HEAD', 'POST').", + ); + } + } + + public function testCustomUnsafeMethodsCsrfHeaderValidation(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $request->csrfHeaderUnsafeMethods = ['POST']; + $request->validateCsrfHeaderOnly = true; + $request->enableCsrfValidation = true; + + // only accept valid custom header on unsafe requests + foreach (['POST'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + + $request->headers->remove(Request::CSRF_HEADER); + + self::assertFalse( + $request->validateCsrfToken(), + "'CSRF' token validation should fail when the custom header is missing for unsafe 'HTTP' methods " . + "('POST').", + ); + + $request->headers->add(Request::CSRF_HEADER, ''); + + self::assertTrue( + $request->validateCsrfToken(), + "'CSRF' token validation should pass when the custom header is present for unsafe 'HTTP' methods " . + "('POST').", + ); + } + + // accept no value on other requests + foreach (['GET', 'HEAD'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + + $request->headers->remove(Request::CSRF_HEADER); + + self::assertTrue( + $request->validateCsrfToken(), + "'CSRF' token validation should pass for safe 'HTTP' methods ('GET', 'HEAD') regardless of custom " . + 'header presence.', + ); + } + } + + public function testForwardedNotTrusted(): void + { + $_SERVER['REMOTE_ADDR'] = '192.168.10.10'; + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['HTTP_FORWARDED'] = 'for=8.8.8.8;host=spoofed.host;proto=https'; + $_SERVER['HTTP_X_FORWARDED_FOR'] = '10.0.0.1'; + $_SERVER['HTTP_X_FORWARDED_HOST'] = 'yiiframework.com'; + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'http'; + + $request = new Request( + [ + 'trustedHosts' => [ + '192.168.10.0/24', + '192.168.20.0/24', + ], + 'secureHeaders' => [ + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + ], + ], + ); + + self::assertSame('10.0.0.1', $request->userIP, 'User IP fail!.'); + self::assertSame('http://yiiframework.com', $request->hostInfo, 'Host info fail!.'); + } + + public function testGetBodyParam(): void + { + $request = new Request(); + + $request->setBodyParams( + [ + 'someParam' => 'some value', + 'param.dot' => 'value.dot', + ], + ); + + self::assertSame( + 'value.dot', + $request->getBodyParam('param.dot'), + "'getBodyParam()' should return the correct value for a parameter with a dot in its name.", + ); + self::assertSame( + null, + $request->getBodyParam('unexisting'), + "'getBodyParam()' should return 'null' for a non-existing parameter.", + ); + self::assertSame( + 'default', + $request->getBodyParam('unexisting', 'default'), + "'getBodyParam()' should return the default value when the parameter does not exist.", + ); + + // @see https://github.com/yiisoft/yii2/issues/14135 + $bodyParams = new stdClass(); + + $bodyParams->someParam = 'some value'; + $bodyParams->{'param.dot'} = 'value.dot'; + + $request->setBodyParams($bodyParams); + + self::assertSame( + 'some value', + $request->getBodyParam('someParam'), + "'getBodyParam()' should return the correct value for an existing parameter in 'stdClass'.", + ); + self::assertSame( + 'value.dot', + $request->getBodyParam('param.dot'), + "'getBodyParam()' should return the correct value for a parameter with a dot in its name in 'stdClass'.", + ); + self::assertSame( + null, + $request->getBodyParam('unexisting'), + "'getBodyParam()' should return 'null' for a non-existing parameter in 'stdClass'.", + ); + self::assertSame( + 'default', + $request->getBodyParam('unexisting', 'default'), + "'getBodyParam()' should return the default value when the parameter does not exist in 'stdClass'.", + ); + } + + /** + * @phpstan-param array $expected + */ + #[DataProviderExternal(RequestProvider::class, 'getBodyParams')] + public function testGetBodyParams(string $contentType, string $rawBody, array $expected): void + { + $_SERVER['CONTENT_TYPE'] = $contentType; + + $request = new Request(); + + $request->parsers = [ + 'application/json' => JsonParser::class, + 'application/javascript' => JsonParser::class, + ]; + + $request->setRawBody($rawBody); + + self::assertSame( + $expected, + $request->getBodyParams(), + "'getBodyParams()' should return the expected array for the provided content type and raw body.", + ); + } + + /** + * @phpstan-param array|array}> $server + * @phpstan-param array $expected + */ + #[DataProviderExternal(RequestProvider::class, 'getHostInfo')] + public function testGetHostInfo(array $server, array $expected): void + { + $original = $_SERVER; + $_SERVER = $server; + + $request = new Request( + [ + 'trustedHosts' => [ + '192.168.0.0/24', + ], + 'secureHeaders' => [ + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'forwarded', + ], + ], + ); + + self::assertEquals( + $expected[0] ?? null, + $request->getHostInfo(), + "'getHostInfo()' should return the expected value for the given 'secureHeaders' and 'trustedHosts' " . + 'configuration.', + ); + self::assertEquals( + $expected[1] ?? null, + $request->getHostName(), + "'getHostName()' should return the expected value for the given 'secureHeaders' and 'trustedHosts' " . + 'configuration.', + ); + + $request = new Request( + [ + 'trustedHosts' => [ + '192.168.0.0/24' => [ + 'X-Forwarded-Host', + 'forwarded', + ], + ], + 'secureHeaders' => [ + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'forwarded', + ], + ], + ); + + self::assertEquals( + $expected[0] ?? null, + $request->getHostInfo(), + "'getHostInfo()' should return the expected value when 'trustedHosts' is an associative array.", + ); + self::assertEquals( + $expected[1] ?? null, + $request->getHostName(), + "'getHostName()' should return the expected value when 'trustedHosts' is an associative array.", + ); + + $_SERVER = $original; + } + + /** + * @phpstan-param array}> $server + */ + #[DataProviderExternal(RequestProvider::class, 'getIsAjax')] + public function testGetIsAjax(array $server, bool $expected): void + { + $original = $_SERVER; + $_SERVER = $server; + + $request = new Request(); + + self::assertEquals( + $expected, + $request->getIsAjax(), + '\'getIsAjax()\' should return the expected value based on the simulated \'$_SERVER\' input.', + ); + + $_SERVER = $original; + } + + /** + * @phpstan-param array}> $server + */ + #[DataProviderExternal(RequestProvider::class, 'getIsPjax')] + public function testGetIsPjax(array $server, bool $expected): void + { + $original = $_SERVER; + $_SERVER = $server; + + $request = new Request(); + + self::assertEquals( + $expected, + $request->getIsPjax(), + '\'getIsPjax()\' should return the expected value based on the simulated \'$_SERVER\' input.', + ); + + $_SERVER = $original; + } + + + /** + * @phpstan-param array}> $server + */ + #[DataProviderExternal(RequestProvider::class, 'isSecureServer')] + public function testGetIsSecureConnection(array $server, bool $expected): void + { + $original = $_SERVER; + $_SERVER = $server; + + $request = new Request( + [ + 'trustedHosts' => [ + '192.168.0.0/24', + ], + 'secureHeaders' => [ + 'Front-End-Https', + 'X-Rewrite-Url', + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'forwarded', + ], + ], + ); + + self::assertEquals( + $expected, + $request->getIsSecureConnection(), + "'getIsSecureConnection()' should return the expected value for the given 'secureHeaders' and " . + "'trustedHosts'.", + ); + + $request = new Request( + [ + 'trustedHosts' => [ + '192.168.0.0/24' => [ + 'Front-End-Https', + 'X-Forwarded-Proto', + 'forwarded', + ], + ], + 'secureHeaders' => [ + 'Front-End-Https', + 'X-Rewrite-Url', + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'forwarded', + ], + ], + ); + + self::assertEquals( + $expected, + $request->getIsSecureConnection(), + "'getIsSecureConnection()' should return the expected value for the associative 'trustedHosts' and " . + "'secureHeaders'.", + ); + + $_SERVER = $original; + } + + /** + * @phpstan-param array}> $server + */ + #[DataProviderExternal(RequestProvider::class, 'isSecureServerWithoutTrustedHost')] + public function testGetIsSecureConnectionWithoutTrustedHost(array $server, bool $expected): void + { + $original = $_SERVER; + $_SERVER = $server; + + $request = new Request( + [ + 'trustedHosts' => [ + '192.168.0.0/24' => [ + 'Front-End-Https', + 'X-Forwarded-Proto', + ], + ], + 'secureHeaders' => [ + 'Front-End-Https', + 'X-Rewrite-Url', + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'forwarded', + ], + ], + ); + + self::assertEquals( + $expected, + $request->getIsSecureConnection(), + "'getIsSecureConnection()' should return the expected value for the associative 'trustedHosts' and " . + "'secureHeaders'.", + ); + + $_SERVER = $original; + } + + /** + * @phpstan-param array}> $server + * @phpstan-param string $expected + */ + #[DataProviderExternal(RequestProvider::class, 'getMethod')] + public function testGetMethod(array $server, string $expected): void + { + $original = $_SERVER; + $_SERVER = $server; + + $request = new Request(); + + self::assertEquals( + $expected, + $request->getMethod(), + '\'getMethod()\' should return the expected value based on the simulated \'$_SERVER\' input.', + ); + + $_SERVER = $original; + } + + public function testGetOrigin(): void + { + $_SERVER['HTTP_ORIGIN'] = 'https://www.w3.org'; + + $request = new Request(); + + self::assertEquals( + 'https://www.w3.org', + $request->getOrigin(), + "'getOrigin()' should return the correct origin when 'HTTP_ORIGIN' is set.", + ); + + unset($_SERVER['HTTP_ORIGIN']); + + $request = new Request(); + + self::assertNull( + $request->getOrigin(), + "'getOrigin()' should return 'null' when 'HTTP_ORIGIN' is not set.", + ); + } + + public function testGetScriptFileWithEmptyServer(): void + { + $request = new Request(); + + $_SERVER = []; + + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Unable to determine the entry script file path.'); + + $request->getScriptFile(); + } + + public function testGetScriptUrlWithEmptyServer(): void + { + $request = new Request(); + + $_SERVER = []; + + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Unable to determine the entry script file path.'); + + $request->getScriptUrl(); + } + + public function testGetServerName(): void + { + $request = new Request(); + + $_SERVER['SERVER_NAME'] = 'servername'; + + self::assertEquals( + 'servername', + $request->getServerName(), + '\'getServerName()\' should return the value of \'$_SERVER[\'SERVER_NAME\']\' when it is set.', + ); + + unset($_SERVER['SERVER_NAME']); + + self::assertNull( + $request->getServerName(), + '\'getServerName()\' should return \'null\' when \'$_SERVER[\'SERVER_NAME\']\' is not set.', + ); + } + + public function testGetServerPort(): void + { + $request = new Request(); + + $_SERVER['SERVER_PORT'] = 33; + + self::assertEquals( + 33, + $request->getServerPort(), + '\'getServerPort()\' should return the value of \'$_SERVER[\'SERVER_PORT\']\' when it is set.', + ); + + unset($_SERVER['SERVER_PORT']); + + self::assertNull( + $request->getServerPort(), + '\'getServerPort()\' should return \'null\' when $_SERVER[\'SERVER_PORT\'] is not set.', + ); + } + + /** + * @phpstan-param array}> $server + * @phpstan-param string $expected + */ + #[DataProviderExternal(RequestProvider::class, 'getUserIP')] + public function testGetUserIP(array $server, string $expected): void + { + $original = $_SERVER; + $_SERVER = $server; + + $request = new Request( + [ + 'trustedHosts' => [ + '192.168.0.0/24', + ], + 'secureHeaders' => [ + 'Front-End-Https', + 'X-Rewrite-Url', + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'forwarded', + ], + ], + ); + + self::assertEquals( + $expected, + $request->getUserIP(), + "'getUserIP()' should return the expected value for the given 'secureHeaders' and 'trustedHosts'.", + ); + + $request = new Request( + [ + 'trustedHosts' => [ + '192.168.0.0/24' => [ + 'X-Forwarded-For', + 'forwarded', + ], + ], + 'secureHeaders' => [ + 'Front-End-Https', + 'X-Rewrite-Url', + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'forwarded', + ], + ], + ); + + self::assertEquals( + $expected, + $request->getUserIP(), + "'getUserIP()' should return the expected value for the associative 'trustedHosts' and 'secureHeaders'.", + ); + + $_SERVER = $original; + } + + /** + * @phpstan-param array}> $server + */ + #[DataProviderExternal(RequestProvider::class, 'getUserIPWithoutTrustedHost')] + public function testGetUserIPWithoutTrustedHost(array $server, string $expected): void + { + $original = $_SERVER; + $_SERVER = $server; + + $request = new Request( + [ + 'trustedHosts' => [ + '192.168.0.0/24' => [ + 'X-Forwarded-For', + ], + ], + 'secureHeaders' => [ + 'Front-End-Https', + 'X-Rewrite-Url', + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'forwarded', + ], + ], + ); + + self::assertEquals( + $expected, + $request->getUserIP(), + "'getUserIP()' should return the expected value for the associative 'trustedHosts' and 'secureHeaders'.", + ); + + $_SERVER = $original; + } + + /** + * @phpstan-param array $expected + */ + #[DataProviderExternal(RequestProvider::class, 'httpAuthorizationHeaders')] + public function testHttpAuthCredentialsFromHttpAuthorizationHeader(string $secret, array $expected): void + { + $original = $_SERVER; + + $request = new Request(); + + $_SERVER['HTTP_AUTHORIZATION'] = "Basic {$secret}"; + + self::assertSame( + $expected, + $request->getAuthCredentials(), + "'getAuthCredentials()' should return the expected credentials from 'HTTP_AUTHORIZATION'.", + ); + self::assertSame( + $expected[0] ?? null, + $request->getAuthUser(), + "'getAuthUser()' should return the expected username from 'HTTP_AUTHORIZATION'.", + ); + self::assertSame( + $expected[1] ?? null, + $request->getAuthPassword(), + "'getAuthPassword()' should return the expected password from 'HTTP_AUTHORIZATION'.", + ); + + $_SERVER = $original; + + $request = new Request(); + + $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = "Basic {$secret}"; + + self::assertSame( + $expected, + $request->getAuthCredentials(), + "'getAuthCredentials()' should return the expected credentials from 'REDIRECT_HTTP_AUTHORIZATION'.", + ); + self::assertSame( + $expected[0] ?? null, + $request->getAuthUser(), + "'getAuthUser()' should return the expected username from 'REDIRECT_HTTP_AUTHORIZATION'.", + ); + self::assertSame( + $expected[1] ?? null, + $request->getAuthPassword(), + "'getAuthPassword()' should return the expected password from 'REDIRECT_HTTP_AUTHORIZATION'.", + ); + + $_SERVER = $original; + } + + public function testHttpAuthCredentialsFromServerSuperglobal(): void + { + $original = $_SERVER; + [$user, $pw] = ['foo', 'bar']; + $_SERVER['PHP_AUTH_USER'] = $user; + $_SERVER['PHP_AUTH_PW'] = $pw; + + $request = new Request(); + + $request->getHeaders()->set('Authorization', 'Basic ' . base64_encode('less-priority:than-PHP_AUTH_*')); + + self::assertSame( + [$user, $pw], + $request->getAuthCredentials(), + "'getAuthCredentials()' should return credentials from 'PHP_AUTH_USER' and 'PHP_AUTH_PW' when set.", + ); + self::assertSame( + $user, + $request->getAuthUser(), + "'getAuthUser()' should return the username from 'PHP_AUTH_USER' when set.", + ); + self::assertSame( + $pw, + $request->getAuthPassword(), + "'getAuthPassword()' should return the password from 'PHP_AUTH_PW' when set.", + ); + + $_SERVER = $original; + } + + public function testIssue15317(): void + { + $this->mockWebApplication(); + + $_COOKIE[(new Request())->csrfParam] = ''; + $request = new Request(); + + $request->enableCsrfCookie = true; + $request->enableCookieValidation = false; + $_SERVER['REQUEST_METHOD'] = 'POST'; + + Yii::$app->security->unmaskToken(''); + + self::assertFalse( + $request->validateCsrfToken(''), + "'validateCsrfToken()' should return 'false' when an empty 'CSRF' token is provided.", + ); + self::assertNotEmpty( + $request->getCsrfToken(), + "'getCsrfToken()' should return a non-empty value after an empty 'CSRF' token is validated.", + ); + } + + public function testNoCsrfTokenCsrfHeaderValidation(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $request->validateCsrfHeaderOnly = true; + + self::assertNull( + $request->getCsrfToken(), + "'getCsrfToken()' should return 'null' when no 'CSRF' token is set.", + ); + } + + public function testParseAcceptHeader(): void + { + $request = new Request(); + + self::assertEquals( + [], + $request->parseAcceptHeader(' '), + "'parseAcceptHeader()' should return an empty array when the header is blank.", + ); + self::assertEquals( + [ + 'audio/basic' => ['q' => 1], + 'audio/*' => ['q' => 0.2], + ], + $request->parseAcceptHeader('audio/*; q=0.2, audio/basic'), + "'parseAcceptHeader()' should correctly parse media types and quality values.", + ); + self::assertEquals( + [ + 'application/json' => ['q' => 1, 'version' => '1.0'], + 'application/xml' => ['q' => 1, 'version' => '2.0', 'x'], + 'text/x-c' => ['q' => 1], + 'text/x-dvi' => ['q' => 0.8], + 'text/plain' => ['q' => 0.5], + ], + $request->parseAcceptHeader( + 'text/plain; q=0.5, + application/json; version=1.0, + application/xml; version=2.0; x, + text/x-dvi; q=0.8, text/x-c', + ), + "'parseAcceptHeader()' should correctly parse complex 'Accept' headers with parameters and quality values.", + ); + } + + #[DataProviderExternal(RequestProvider::class, 'parseForwardedHeader')] + public function testParseForwardedHeaderParts( + string $remoteAddress, + string $forwardedHeader, + string $expectedHostInfo, + string $expectedUserIp, + ): void { + $_SERVER['REMOTE_ADDR'] = $remoteAddress; + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['HTTP_FORWARDED'] = $forwardedHeader; + + $request = new Request( + [ + 'trustedHosts' => [ + '192.168.10.0/24', + '192.168.20.0/24', + ], + 'secureHeaders' => [ + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'forwarded', + ], + ], + ); + + self::assertSame( + $expectedUserIp, + $request->userIP, + "'userIP' should match the expected value parsed from the 'Forwarded' header.", + ); + self::assertSame( + $expectedHostInfo, + $request->hostInfo, + "'hostInfo' should match the expected value parsed from the 'Forwarded' header.", + ); + } + + public function testPreferredLanguage(): void + { + $this->mockApplication( + [ + 'language' => 'en', + ], + ); + + $request = new Request(); + + $request->acceptableLanguages = []; + + self::assertEquals( + 'en', + $request->getPreferredLanguage(), + "Should return 'en' when no 'acceptableLanguages' are set.", + ); + + $request = new Request(); + + $request->acceptableLanguages = ['de']; + + self::assertEquals( + 'en', + $request->getPreferredLanguage(), + "Should return 'en' when 'acceptableLanguages' does not match the default.", + ); + + $request = new Request(); + + $request->acceptableLanguages = ['en-us', 'de', 'ru-RU']; + + self::assertEquals( + 'en', + $request->getPreferredLanguage(['en']), + "Should return 'en' when 'en' is in the preferred list.", + ); + + $request = new Request(); + + $request->acceptableLanguages = ['en-us', 'de', 'ru-RU']; + + self::assertEquals( + 'de', + $request->getPreferredLanguage(['ru', 'de']), + "Should return 'de' when 'de' is in the preferred list.", + ); + self::assertEquals( + 'de-DE', + $request->getPreferredLanguage(['ru', 'de-DE']), + "Should return 'de-DE' when 'de-DE' is in the preferred list.", + ); + + $request = new Request(); + + $request->acceptableLanguages = ['en-us', 'de', 'ru-RU']; + + self::assertEquals( + 'de', + $request->getPreferredLanguage(['de', 'ru']), + "Should return 'de' when 'de' is the first match in the preferred list.", + ); + + $request = new Request(); + + $request->acceptableLanguages = ['en-us', 'de', 'ru-RU']; + + self::assertEquals( + 'ru-ru', + $request->getPreferredLanguage(['ru-ru']), + "Should return 'ru-ru' when 'ru-ru' is in the preferred list.", + ); + + $request = new Request(); + + $request->acceptableLanguages = ['en-us', 'de']; + + self::assertEquals( + 'ru-ru', + $request->getPreferredLanguage(['ru-ru', 'pl']), + "Should return 'ru-ru' when 'ru-ru' is the first in the preferred list.", + ); + self::assertEquals( + 'ru-RU', + $request->getPreferredLanguage(['ru-RU', 'pl']), + "Should return 'ru-RU' when 'ru-RU' is the first in the preferred list.", + ); + + $request = new Request(); + + $request->acceptableLanguages = ['en-us', 'de']; + + self::assertEquals( + 'pl', + $request->getPreferredLanguage(['pl', 'ru-ru']), + "Should return 'pl' when 'pl' is the first in the preferred list and not present in 'acceptableLanguages'.", + ); + } + + #[TestWith(['POST', 'GET', 'POST'])] + #[TestWith(['POST', 'OPTIONS', 'POST'])] + #[TestWith(['POST', 'HEAD', 'POST'])] + #[TestWith(['POST', 'DELETE', 'DELETE'])] + #[TestWith(['POST', 'CUSTOM', 'CUSTOM'])] + public function testRequestMethodCanNotBeDowngraded( + string $requestMethod, + string $requestOverrideMethod, + string $expectedMethod, + ): void { + $request = new Request(); + + $_SERVER['REQUEST_METHOD'] = $requestMethod; + $_POST[$request->methodParam] = $requestOverrideMethod; + + self::assertSame( + $expectedMethod, + $request->getMethod(), + "'getMethod()' should return the expected 'HTTP' method after considering override logic.", + ); + } + + public function testResolve(): void + { + $this->mockWebApplication( + [ + 'components' => [ + 'urlManager' => [ + 'cache' => null, + 'enablePrettyUrl' => true, + 'showScriptName' => false, + 'rules' => [ + 'posts' => 'post/list', + 'post/' => 'post/view', + ], + ], + ], + ], + ); + + $request = new Request(); + + $request->pathInfo = 'posts'; + $_GET['page'] = 1; + + $result = $request->resolve(); + + self::assertEquals( + [ + 'post/list', + ['page' => 1], + ], + $result, + '\'resolve()\' should return the correct route and query parameters when \'page\' is set in \'$_GET\'.', + ); + self::assertEquals( + ['page' => 1], + $_GET, + '\'$_GET\' should contain only the \'page\' parameter after resolving the \'posts\' route.', + ); + + $request->setQueryParams(['page' => 5]); + $result = $request->resolve(); + + self::assertEquals( + [ + 'post/list', + ['page' => 5], + ], + $result, + "'resolve()' should return the correct route and query parameters when 'page' is set via 'setQueryParams()'.", + ); + self::assertEquals( + ['page' => 1], + $_GET, + '\'$_GET\' should remain unchanged after \'setQueryParams()\' is used on the request object.', + ); + + $request->setQueryParams(['custom-page' => 5]); + $result = $request->resolve(); + + self::assertEquals( + [ + 'post/list', + ['custom-page' => 5], + ], + $result, + "'resolve()' should return the correct route and custom query parameters when 'setQueryParams()' is used.", + ); + self::assertEquals( + ['page' => 1], + $_GET, + '\'$_GET\' should not be affected by custom query parameters set via \'setQueryParams()\'.', + ); + + unset($_GET['page']); + + $request = new Request(); + + $request->pathInfo = 'post/21'; + + self::assertEquals( + [], + $_GET, + '\$_GET\' should be empty after unsetting the \'page\' parameter and before resolving a new route.', + ); + + $result = $request->resolve(); + + self::assertEquals( + [ + 'post/view', + ['id' => 21], + ], + $result, + "'resolve()' should return the correct route and parameters when resolving a path with an \'id\'.", + ); + self::assertEquals( + ['id' => 21], + $_GET, + '\'$_GET\' should contain the \'id\' parameter after resolving the \'post/21\' route.', + ); + + $_GET['id'] = 42; + + $result = $request->resolve(); + + self::assertEquals( + [ + 'post/view', + ['id' => 21], + ], + $result, + '\'resolve()\' should return the same route and parameters even if \'$_GET[\'id\']\' is set to a ' . + 'different value before resolving.', + ); + self::assertEquals( + ['id' => 21], + $_GET, + '\'$_GET\' should be overwritten with the resolved \'id\' parameter after resolving the route.', + ); + + $_GET['id'] = 63; + + $request->setQueryParams(['token' => 'secret']); + $result = $request->resolve(); + + self::assertEquals( + [ + 'post/view', + [ + 'id' => 21, + 'token' => 'secret', + ], + ], + $result, + "'resolve()' should merge additional query parameters set via 'setQueryParams()' with the resolved route " . + 'parameters.', + ); + self::assertEquals( + ['id' => 63], + $_GET, + '\'$_GET\' should remain unchanged by \'setQueryParams()\' after resolving the route with extra parameters.', + ); + } + + public function testSetHostInfo(): void + { + $request = new Request(); + + unset($_SERVER['SERVER_NAME'], $_SERVER['HTTP_HOST']); + + self::assertNull( + $request->getHostInfo(), + "'getHostInfo()' should return 'null' when no host information is available in the server variables.", + ); + self::assertNull( + $request->getHostName(), + "'getHostName()' should return 'null' when no host information is available in the server variables.", + ); + + $request->setHostInfo('http://servername.com:80'); + + self::assertSame( + 'http://servername.com:80', + $request->getHostInfo(), + "'getHostInfo()' should return the value set by 'setHostInfo()'.", + ); + self::assertSame( + 'servername.com', + $request->getHostName(), + "'getHostName()' should return the host name extracted from the value set by 'setHostInfo()'.", + ); + } + + /** + * @phpstan-param array|null $ipHeaders + * @phpstan-param array $trustedHosts + * + * @throws InvalidConfigException + */ + #[DataProviderExternal(RequestProvider::class, 'trustedHostAndInjectedXForwardedFor')] + public function testTrustedHostAndInjectedXForwardedFor( + string $remoteAddress, + string $xForwardedFor, + array|null $ipHeaders, + array $trustedHosts, + string $expectedUserIp, + ): void { + $_SERVER['REMOTE_ADDR'] = $remoteAddress; + $_SERVER['HTTP_X_FORWARDED_FOR'] = $xForwardedFor; + $params = ['trustedHosts' => $trustedHosts]; + + if ($ipHeaders !== null) { + $params['ipHeaders'] = $ipHeaders; + } + + $request = new Request($params); + + self::assertSame( + $expectedUserIp, + $request->getUserIP(), + "'getUserIP()' should return the expected user 'IP', considering trusted hosts and the 'X-Forwarded-For'" . + 'header with possible injection attempts.', + ); + } + + /** + * @phpstan-param array|null $trustedHosts + */ + #[DataProviderExternal(RequestProvider::class, 'trustedHostAndXForwardedPort')] + public function testTrustedHostAndXForwardedPort( + string $remoteAddress, + int $requestPort, + int|null $xForwardedPort, + array|null $trustedHosts, + int $expectedPort, + ): void { + $_SERVER['REMOTE_ADDR'] = $remoteAddress; + $_SERVER['SERVER_PORT'] = $requestPort; + $_SERVER['HTTP_X_FORWARDED_PORT'] = $xForwardedPort; + $params = ['trustedHosts' => $trustedHosts]; + + $request = new Request($params); + + self::assertSame( + $expectedPort, + $request->getServerPort(), + "'getServerPort()' should return the expected 'PORT', considering trusted hosts and the 'X-Forwarded-Port' " . + 'header when present.', + ); + } +} diff --git a/tests/provider/RequestProvider.php b/tests/provider/RequestProvider.php new file mode 100644 index 00000000..1fe21f8f --- /dev/null +++ b/tests/provider/RequestProvider.php @@ -0,0 +1,879 @@ +, + * resolvedXForwardedForWithHttps: array + * } + */ + public static function alreadyResolvedIp(): array + { + return [ + 'resolvedXForwardedFor' => [ + '50.0.0.1', + '1.1.1.1, 8.8.8.8, 9.9.9.9', + 'http', + [ + '0.0.0.0/0', + ], + // checks: + '50.0.0.1', + '50.0.0.1', + false, + ], + 'resolvedXForwardedForWithHttps' => [ + '50.0.0.1', + '1.1.1.1, 8.8.8.8, 9.9.9.9', + 'https', + [ + '0.0.0.0/0', + ], + // checks: + '50.0.0.1', + '50.0.0.1', + true, + ], + ]; + } + + /** + * @phpstan-return array{ + * get: array{string, string, array}, + * json: array{string, string, array}, + * jsonp: array{string, string, array} + * } + */ + public static function getBodyParams(): array + { + return [ + 'get' => [ + 'application/x-www-form-urlencoded', + 'foo=bar&baz=1', + [ + 'foo' => 'bar', + 'baz' => '1', + ], + ], + 'json' => [ + 'application/json', + '{"foo":"bar","baz":1}', + [ + 'foo' => 'bar', + 'baz' => 1, + ], + ], + 'jsonp' => [ + 'application/javascript', + 'parseResponse({"foo":"bar","baz":1});', + [ + 'foo' => 'bar', + 'baz' => 1, + ], + ], + ]; + } + + /** + * @return array|array, array{string|null, string|null}}> + */ + public static function getHostInfo(): array + { + return [ + // empty + [ + [], + [ + null, + null, + ], + ], + // normal + [ + [ + 'HTTP_HOST' => 'example1.com', + 'SERVER_NAME' => 'example2.com', + ], + [ + 'http://example1.com', + 'example1.com', + ], + ], + // HTTP header missing + [ + ['SERVER_NAME' => 'example2.com'], + [ + 'http://example2.com', + 'example2.com', + ], + ], + // forwarded from untrusted server + [ + [ + 'HTTP_X_FORWARDED_HOST' => 'example3.com', + 'HTTP_HOST' => 'example1.com', + 'SERVER_NAME' => 'example2.com', + ], + [ + 'http://example1.com', + 'example1.com', + ], + ], + // forwarded from trusted proxy + [ + [ + 'HTTP_X_FORWARDED_HOST' => 'example3.com', + 'HTTP_HOST' => 'example1.com', + 'SERVER_NAME' => 'example2.com', + 'REMOTE_ADDR' => '192.168.0.1', + ], + [ + 'http://example3.com', + 'example3.com', + ], + ], + // forwarded from trusted proxy + [ + [ + 'HTTP_X_FORWARDED_HOST' => 'example3.com, example2.com', + 'HTTP_HOST' => 'example1.com', + 'SERVER_NAME' => 'example2.com', + 'REMOTE_ADDR' => '192.168.0.1', + ], + [ + 'http://example3.com', + 'example3.com', + ], + ], + // RFC 7239 forwarded from untrusted server + [ + [ + 'HTTP_FORWARDED' => 'host=example3.com', + 'HTTP_HOST' => 'example1.com', + 'SERVER_NAME' => 'example2.com', + ], + [ + 'http://example1.com', + 'example1.com', + ], + ], + // RFC 7239 forwarded from trusted proxy + [ + [ + 'HTTP_FORWARDED' => 'host=example3.com', + 'HTTP_HOST' => 'example1.com', + 'REMOTE_ADDR' => '192.168.0.1', + ], + [ + 'http://example3.com', + 'example3.com', + ], + ], + // RFC 7239 forwarded from trusted proxy + [ + [ + 'HTTP_FORWARDED' => 'host=example3.com,host=example2.com', + 'HTTP_HOST' => 'example1.com', + 'REMOTE_ADDR' => '192.168.0.1', + ], + [ + 'http://example2.com', + 'example2.com', + ], + ], + ]; + } + + /** + * @phpstan-return array, bool}> + */ + public static function getIsAjax(): array + { + return [ + [ + [], + false, + ], + [ + [ + 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', + ], + true, + ], + ]; + } + + /** + * @phpstan-return array, bool}> + */ + public static function getIsPjax(): array + { + return [ + [ + [], + false, + ], + [ + [ + 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', + 'HTTP_X_PJAX' => 'any value', + ], + true, + ], + ]; + } + + /** + * @phpstan-return array, string}> + */ + public static function getMethod(): array + { + return [ + [ + [ + 'REQUEST_METHOD' => 'DEFAULT', + 'HTTP_X-HTTP-METHOD-OVERRIDE' => 'OVERRIDE', + ], + 'OVERRIDE', + ], + [ + ['REQUEST_METHOD' => 'DEFAULT'], + 'DEFAULT', + ], + ]; + } + + /** + * @phpstan-return array, string}> + */ + public static function getUserIP(): array + { + return [ + [ + [ + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'HTTP_X_FORWARDED_FOR' => '123.123.123.123', + 'REMOTE_ADDR' => '192.168.0.1', + ], + '123.123.123.123', + ], + [ + [ + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'HTTP_X_FORWARDED_FOR' => '123.123.123.123', + 'REMOTE_ADDR' => '192.169.1.1', + ], + '192.169.1.1', + ], + [ + [ + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'HTTP_X_FORWARDED_FOR' => '123.123.123.123', + 'REMOTE_HOST' => 'untrusted.com', + 'REMOTE_ADDR' => '192.169.1.1', + ], + '192.169.1.1', + ], + [ + [ + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'HTTP_X_FORWARDED_FOR' => '192.169.1.1', + 'REMOTE_HOST' => 'untrusted.com', + 'REMOTE_ADDR' => '192.169.1.1', + ], + '192.169.1.1', + ], + // RFC 7239 forwarded from trusted proxy + [ + [ + 'HTTP_FORWARDED' => 'for=123.123.123.123', + 'REMOTE_ADDR' => '192.168.0.1', + ], + '123.123.123.123', + ], + // RFC 7239 forwarded from trusted proxy with optinal port + [ + [ + 'HTTP_FORWARDED' => 'for=123.123.123.123:2222', + 'REMOTE_ADDR' => '192.168.0.1', + ], + '123.123.123.123', + ], + // RFC 7239 forwarded from trusted proxy, through another proxy + [ + [ + 'HTTP_FORWARDED' => 'for=123.123.123.123,for=122.122.122.122', + 'REMOTE_ADDR' => '192.168.0.1', + ], + '122.122.122.122', + ], + // RFC 7239 forwarded from trusted proxy, through another proxy, client IP with optional port + [ + [ + 'HTTP_FORWARDED' => 'for=123.123.123.123:2222,for=122.122.122.122:2222', + 'REMOTE_ADDR' => '192.168.0.1', + ], + '122.122.122.122', + ], + // RFC 7239 forwarded from untrusted proxy + [ + [ + 'HTTP_FORWARDED' => 'for=123.123.123.123', + 'REMOTE_ADDR' => '192.169.1.1', + ], + '192.169.1.1', + ], + // RFC 7239 forwarded from trusted proxy with optional port + [ + [ + 'HTTP_FORWARDED' => 'for=123.123.123.123:2222', + 'REMOTE_ADDR' => '192.169.1.1', + ], + '192.169.1.1', + ], + // RFC 7239 forwarded from trusted proxy with client IPv6 + [ + [ + 'HTTP_FORWARDED' => 'for="2001:0db8:85a3:0000:0000:8a2e:0370:7334"', + 'REMOTE_ADDR' => '192.168.0.1', + ], + '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + ], + // RFC 7239 forwarded from trusted proxy with client IPv6 and optional port + [ + [ + 'HTTP_FORWARDED' => 'for="[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:2222"', + 'REMOTE_ADDR' => '192.168.0.1', + ], + '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + ], + // RFC 7239 forwarded from trusted proxy, through another proxy with client IPv6 + [ + [ + 'HTTP_FORWARDED' => 'for=122.122.122.122,for="2001:0db8:85a3:0000:0000:8a2e:0370:7334"', + 'REMOTE_ADDR' => '192.168.0.1', + ], + '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + ], + // RFC 7239 forwarded from trusted proxy, through another proxy with client IPv6 and optional port + [ + [ + 'HTTP_FORWARDED' => 'for=122.122.122.122:2222,for="[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:2222"', + 'REMOTE_ADDR' => '192.168.0.1', + ], + '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + ], + // RFC 7239 forwarded from untrusted proxy with client IPv6 + [ + [ + 'HTTP_FORWARDED' => 'for"=2001:0db8:85a3:0000:0000:8a2e:0370:7334"', + 'REMOTE_ADDR' => '192.169.1.1', + ], + '192.169.1.1', + ], + // RFC 7239 forwarded from untrusted proxy, through another proxy with client IPv6 and optional port + [ + [ + 'HTTP_FORWARDED' => 'for="[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:2222"', + 'REMOTE_ADDR' => '192.169.1.1', + ], + '192.169.1.1', + ], + ]; + } + + /** + * @phpstan-return array, string}> + */ + public static function getUserIPWithoutTrustedHost(): array + { + return [ + // RFC 7239 forwarded is not enabled + [ + [ + 'HTTP_FORWARDED' => 'for=123.123.123.123', + 'REMOTE_ADDR' => '192.168.0.1', + ], + '192.168.0.1', + ], + ]; + } + + /** + * @phpstan-return array + */ + public static function httpAuthorizationHeaders(): array + { + return [ + [ + 'not a base64 at all', + [ + base64_decode('not a base64 at all', true), + null, + ], + ], + [ + base64_encode('user:'), + [ + 'user', + null, + ], + ], + [ + base64_encode('user'), + [ + 'user', + null, + ], + ], + [ + base64_encode('user:pw'), + [ + 'user', + 'pw', + ], + ], + [ + base64_encode('user:pw'), + [ + 'user', + 'pw', + ], + ], + [ + base64_encode('user:a:b'), + [ + 'user', + 'a:b', + ], + ], + [ + base64_encode(':a:b'), + [ + null, + 'a:b', + ], + ], + [ + base64_encode(':'), + [ + null, + null, + ], + ], + ]; + } + + /** + * @phpstan-return array, bool}> + */ + public static function isSecureServer(): array + { + return [ + [ + ['HTTPS' => 1], + true, + ], + [ + ['HTTPS' => 'on'], + true, + ], + [ + ['HTTPS' => 0], + false, + ], + [ + ['HTTPS' => 'off'], + false, + ], + [ + [], + false, + ], + [ + ['HTTP_X_FORWARDED_PROTO' => 'https'], + false, + ], + [ + ['HTTP_X_FORWARDED_PROTO' => 'http'], + false, + ], + [ + [ + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'REMOTE_HOST' => 'test.com', + ], + false, + ], + [ + [ + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'REMOTE_HOST' => 'othertest.com', + ], + false, + ], + [ + [ + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'REMOTE_ADDR' => '192.168.0.1', + ], + true, + ], + [ + [ + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'REMOTE_ADDR' => '192.169.0.1', + ], + false, + ], + [ + ['HTTP_FRONT_END_HTTPS' => 'on'], + false, + ], + [ + ['HTTP_FRONT_END_HTTPS' => 'off'], + false, + ], + [ + [ + 'HTTP_FRONT_END_HTTPS' => 'on', + 'REMOTE_HOST' => 'test.com', + ], + false, + ], + [ + [ + 'HTTP_FRONT_END_HTTPS' => 'on', + 'REMOTE_HOST' => 'othertest.com', + ], + false, + ], + [ + [ + 'HTTP_FRONT_END_HTTPS' => 'on', + 'REMOTE_ADDR' => '192.168.0.1', + ], + true, + ], + [ + [ + 'HTTP_FRONT_END_HTTPS' => 'on', + 'REMOTE_ADDR' => '192.169.0.1', + ], + false, + ], + // RFC 7239 forwarded from untrusted proxy + [ + ['HTTP_FORWARDED' => 'proto=https'], + false, + ], + // RFC 7239 forwarded from two untrusted proxies + [ + ['HTTP_FORWARDED' => 'proto=https,proto=http'], + false, + ], + // RFC 7239 forwarded from trusted proxy + [ + [ + 'HTTP_FORWARDED' => 'proto=https', + 'REMOTE_ADDR' => '192.168.0.1', + ], + true, + ], + // RFC 7239 forwarded from trusted proxy, second proxy not encrypted + [ + [ + 'HTTP_FORWARDED' => 'proto=https,proto=http', + 'REMOTE_ADDR' => '192.168.0.1', + ], + false, + ], + // RFC 7239 forwarded from trusted proxy, second proxy encrypted, while client request not encrypted + [ + [ + 'HTTP_FORWARDED' => 'proto=http,proto=https', + 'REMOTE_ADDR' => '192.168.0.1', + ], + true, + ], + // RFC 7239 forwarded from untrusted proxy + [ + [ + 'HTTP_FORWARDED' => 'proto=https', + 'REMOTE_ADDR' => '192.169.0.1', + ], + false, + ], + // RFC 7239 forwarded from untrusted proxy, second proxy not encrypted + [ + [ + 'HTTP_FORWARDED' => 'proto=https,proto=http', + 'REMOTE_ADDR' => '192.169.0.1', + ], + false, + ], + // RFC 7239 forwarded from untrusted proxy, second proxy encrypted, while client request not encrypted + [ + [ + 'HTTP_FORWARDED' => 'proto=http,proto=https', + 'REMOTE_ADDR' => '192.169.0.1', + ], + false, + ], + ]; + } + + /** + * @phpstan-return array, bool}> + */ + public static function isSecureServerWithoutTrustedHost(): array + { + return [ + // RFC 7239 forwarded header is not enabled + [ + [ + 'HTTP_FORWARDED' => 'proto=https', + 'REMOTE_ADDR' => '192.168.0.1', + ], + false, + ], + ]; + } + + /** + * @phpstan-return array + */ + public static function parseForwardedHeader(): array + { + return [ + [ + '192.168.10.10', + 'for=10.0.0.2;host=yiiframework.com;proto=https', + 'https://yiiframework.com', + '10.0.0.2', + ], + [ + '192.168.10.10', + 'for=10.0.0.2;proto=https', + 'https://example.com', + '10.0.0.2', + ], + [ + '192.168.10.10', + 'host=yiiframework.com;proto=https', + 'https://yiiframework.com', + '192.168.10.10', + ], + [ + '192.168.10.10', + 'host=yiiframework.com;for=10.0.0.2', + 'http://yiiframework.com', + '10.0.0.2', + ], + [ + '192.168.20.10', + 'host=yiiframework.com;for=10.0.0.2;proto=https', + 'https://yiiframework.com', + '10.0.0.2', + ], + [ + '192.168.10.10', + 'for=10.0.0.1;host=yiiframework.com;proto=https, for=192.168.20.20;host=awesome.proxy.com;proto=http', + 'https://yiiframework.com', + '10.0.0.1', + ], + [ + '192.168.10.10', + 'for=8.8.8.8;host=spoofed.host;proto=https, for=10.0.0.1;host=yiiframework.com;proto=https, for=192.168.20.20;host=trusted.proxy;proto=http', + 'https://yiiframework.com', + '10.0.0.1', + ], + ]; + } + + /** + * @phpstan-return array< + * string, + * array{string, string, array|null, array, string} + * > + */ + public static function trustedHostAndInjectedXForwardedFor(): array + { + return [ + 'emptyIPs' => [ + '1.1.1.1', + '', + null, + ['10.10.10.10'], + '1.1.1.1', + ], + 'invalidIp' => [ + '1.1.1.1', + '127.0.0.1, 8.8.8.8, 2.2.2.2, apple', + null, + ['10.10.10.10'], + '1.1.1.1', + ], + 'invalidIp2' => [ + '1.1.1.1', + '127.0.0.1, 8.8.8.8, 2.2.2.2, 300.300.300.300', + null, + ['10.10.10.10'], + '1.1.1.1', + ], + 'invalidIp3' => [ + '1.1.1.1', + '127.0.0.1, 8.8.8.8, 2.2.2.2, 10.0.0.0/26', + null, + ['10.0.0.0/24'], + '1.1.1.1', + ], + 'invalidLatestIp' => [ + '1.1.1.1', + '127.0.0.1, 8.8.8.8, 2.2.2.2, apple, 2.2.2.2', + null, + [ + '1.1.1.1', + '2.2.2.2', + ], + '2.2.2.2', + ], + 'notTrusted' => [ + '1.1.1.1', + '127.0.0.1, 8.8.8.8, 2.2.2.2', + null, + ['10.10.10.10'], + '1.1.1.1', + ], + 'trustedLevel1' => [ + '1.1.1.1', '127.0.0.1, 8.8.8.8, 2.2.2.2', + null, + ['1.1.1.1'], + '2.2.2.2', + ], + 'trustedLevel2' => [ + '1.1.1.1', + '127.0.0.1, 8.8.8.8, 2.2.2.2', + null, + [ + '1.1.1.1', + '2.2.2.2', + ], + '8.8.8.8', + ], + 'trustedLevel3' => [ + '1.1.1.1', + '127.0.0.1, 8.8.8.8, 2.2.2.2', + null, + [ + '1.1.1.1', + '2.2.2.2', + '8.8.8.8', + ], + '127.0.0.1', + ], + 'trustedLevel4' => [ + '1.1.1.1', + '127.0.0.1, 8.8.8.8, 2.2.2.2', + null, + [ + '1.1.1.1', + '2.2.2.2', + '8.8.8.8', + '127.0.0.1', + ], + '127.0.0.1', + ], + 'trustedLevel4EmptyElements' => [ + '1.1.1.1', + '127.0.0.1, 8.8.8.8,,,, , , 2.2.2.2', + null, + [ + '1.1.1.1', + '2.2.2.2', + '8.8.8.8', + '127.0.0.1', + ], + '127.0.0.1', + ], + 'trustedWithCidr' => [ + '10.0.0.2', + '127.0.0.1, 8.8.8.8, 10.0.0.240, 10.0.0.32, 10.0.0.99', + null, + ['10.0.0.0/24'], + '8.8.8.8', + ], + 'trustedAll' => [ + '10.0.0.2', + '127.0.0.1, 8.8.8.8, 10.0.0.240, 10.0.0.32, 10.0.0.99', + null, + ['0.0.0.0/0'], + '127.0.0.1', + ], + 'emptyIpHeaders' => [ + '1.1.1.1', '127.0.0.1, 8.8.8.8, 2.2.2.2', + [], + ['1.1.1.1'], + '1.1.1.1', + ], + ]; + } + + /** + * @phpstan-return array|null, int}> + */ + public static function trustedHostAndXForwardedPort(): array + { + return [ + 'defaultPlain' => [ + '1.1.1.1', + 80, + null, + null, + 80, + ], + 'defaultSSL' => [ + '1.1.1.1', + 443, + null, + null, + 443, + ], + 'trustedForwardedPlain' => [ + '10.10.10.10', + 443, + 80, + ['10.0.0.0/8'], + 80, + ], + 'trustedForwardedSSL' => [ + '10.10.10.10', + 80, + 443, + ['10.0.0.0/8'], + 443, + ], + 'untrustedForwardedPlain' => [ + '1.1.1.1', + 443, + 80, + ['10.0.0.0/8'], + 443, + ], + 'untrustedForwardedSSL' => [ + '1.1.1.1', + 80, + 443, + ['10.0.0.0/8'], + 80, + ], + ]; + } +} From 9f6805c95cc08abaa55d556b47ef8cc0c224727a Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sat, 19 Jul 2025 18:09:29 +0000 Subject: [PATCH 02/32] Apply fixes from StyleCI --- src/adapter/ServerRequestAdapter.php | 2 +- src/emitter/SapiEmitter.php | 2 +- src/http/Request.php | 2 +- tests/TestCase.php | 4 ++-- tests/http/RequestTest.php | 1 - tests/provider/RequestProvider.php | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index f4b2db9e..fc9431d1 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -130,7 +130,7 @@ public function getMethodWithOverride(string $methodParam = '_method'): string // Check for X-Http-Method-Override header $headers = $this->psrRequest->getHeaders(); - if (isset($headers['X-Http-Method-Override']) && isset($headers['X-Http-Method-Override'][0])) { + if (isset($headers['X-Http-Method-Override'], $headers['X-Http-Method-Override'][0])) { return strtoupper($headers['X-Http-Method-Override'][0]); } diff --git a/src/emitter/SapiEmitter.php b/src/emitter/SapiEmitter.php index 1619ea08..9f8b5957 100644 --- a/src/emitter/SapiEmitter.php +++ b/src/emitter/SapiEmitter.php @@ -170,7 +170,7 @@ private function emitBodyRange(StreamInterface $body, int $first, int $last): vo private function emitHeaders(ResponseInterface $response): void { foreach ($response->getHeaders() as $name => $values) { - $name = str_replace(' ', '-', ucwords(strtolower(str_replace('-', ' ', (String) $name)))); + $name = str_replace(' ', '-', ucwords(strtolower(str_replace('-', ' ', (string) $name)))); match ($name) { 'Set-Cookie' => array_map( diff --git a/src/http/Request.php b/src/http/Request.php index 45d0d99b..19695c0c 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -121,7 +121,7 @@ public function getScriptUrl(): string } /** - * @return array> + * @return array|UploadedFileInterface> */ public function getUploadedFiles(): array { diff --git a/tests/TestCase.php b/tests/TestCase.php index 9b4c6b32..c2e818cb 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -45,7 +45,7 @@ protected function tearDown(): void */ protected function mockApplication($config = []): void { - new \yii\console\Application( + new Yii\console\Application( ArrayHelper::merge( [ 'id' => 'testapp', @@ -67,7 +67,7 @@ protected function mockApplication($config = []): void */ protected function mockWebApplication($config = []): void { - new \yii\web\Application( + new Yii\web\Application( ArrayHelper::merge( [ 'id' => 'testapp', diff --git a/tests/http/RequestTest.php b/tests/http/RequestTest.php index 61e0298e..f7cedf4e 100644 --- a/tests/http/RequestTest.php +++ b/tests/http/RequestTest.php @@ -630,7 +630,6 @@ public function testGetIsPjax(array $server, bool $expected): void $_SERVER = $original; } - /** * @phpstan-param array}> $server */ diff --git a/tests/provider/RequestProvider.php b/tests/provider/RequestProvider.php index 1fe21f8f..9386f323 100644 --- a/tests/provider/RequestProvider.php +++ b/tests/provider/RequestProvider.php @@ -82,7 +82,7 @@ public static function getBodyParams(): array } /** - * @return array|array, array{string|null, string|null}}> + * @return array|array, array{string|null, string|null}}> */ public static function getHostInfo(): array { From b05e8a9369035982a08ee7601d5b5421185e526d Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 19 Jul 2025 14:17:32 -0400 Subject: [PATCH 03/32] Fix namespace casing for `Yii` application instantiation in `TestCase`. --- .styleci.yml | 2 +- tests/TestCase.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.styleci.yml b/.styleci.yml index 3f624369..5f57952c 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -41,7 +41,6 @@ enabled: - no_unneeded_curly_braces - no_unneeded_final_method - no_unset_cast - - no_unused_imports - no_unused_lambda_imports - no_useless_else - no_useless_return @@ -84,5 +83,6 @@ enabled: disabled: - function_declaration - new_with_parentheses + - no_unused_imports - psr12_braces - psr12_class_definition diff --git a/tests/TestCase.php b/tests/TestCase.php index c2e818cb..9b4c6b32 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -45,7 +45,7 @@ protected function tearDown(): void */ protected function mockApplication($config = []): void { - new Yii\console\Application( + new \yii\console\Application( ArrayHelper::merge( [ 'id' => 'testapp', @@ -67,7 +67,7 @@ protected function mockApplication($config = []): void */ protected function mockWebApplication($config = []): void { - new Yii\web\Application( + new \yii\web\Application( ArrayHelper::merge( [ 'id' => 'testapp', From aa037f131ea7ee429144483cdf7ad409b7ec2f7b Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 19 Jul 2025 14:18:35 -0400 Subject: [PATCH 04/32] Remove unused imports from StyleCI configuration. --- .styleci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.styleci.yml b/.styleci.yml index 5f57952c..4bc683e3 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -83,6 +83,5 @@ enabled: disabled: - function_declaration - new_with_parentheses - - no_unused_imports - psr12_braces - psr12_class_definition From 8ff930bce0a7343ada53f0537b84bf6fc8c34a7d Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sat, 19 Jul 2025 18:19:18 +0000 Subject: [PATCH 05/32] Apply fixes from StyleCI --- tests/TestCase.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index 9b4c6b32..c2e818cb 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -45,7 +45,7 @@ protected function tearDown(): void */ protected function mockApplication($config = []): void { - new \yii\console\Application( + new Yii\console\Application( ArrayHelper::merge( [ 'id' => 'testapp', @@ -67,7 +67,7 @@ protected function mockApplication($config = []): void */ protected function mockWebApplication($config = []): void { - new \yii\web\Application( + new Yii\web\Application( ArrayHelper::merge( [ 'id' => 'testapp', From 97bceeb44838bc5465441037d33d5ef5fbab1a4d Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 19 Jul 2025 14:20:39 -0400 Subject: [PATCH 06/32] Refactor TestCase to use aliases for Application classes and enable no_unused_imports in StyleCI. --- .styleci.yml | 1 + tests/TestCase.php | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.styleci.yml b/.styleci.yml index 4bc683e3..3f624369 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -41,6 +41,7 @@ enabled: - no_unneeded_curly_braces - no_unneeded_final_method - no_unset_cast + - no_unused_imports - no_unused_lambda_imports - no_useless_else - no_useless_return diff --git a/tests/TestCase.php b/tests/TestCase.php index 9b4c6b32..556a1fe7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,7 +5,9 @@ namespace yii2\extensions\psrbridge\tests; use Yii; +use yii\console\Application as ConsoleApplication; use yii\helpers\ArrayHelper; +use yii\web\Application as WebApplication; use yii2\extensions\psrbridge\http\Request; abstract class TestCase extends \PHPUnit\Framework\TestCase @@ -45,7 +47,7 @@ protected function tearDown(): void */ protected function mockApplication($config = []): void { - new \yii\console\Application( + new ConsoleApplication( ArrayHelper::merge( [ 'id' => 'testapp', @@ -67,7 +69,7 @@ protected function mockApplication($config = []): void */ protected function mockWebApplication($config = []): void { - new \yii\web\Application( + new WebApplication( ArrayHelper::merge( [ 'id' => 'testapp', From 77c76cf2ff1ef9552f81ac1ab517efc02abac075 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 19 Jul 2025 14:36:14 -0400 Subject: [PATCH 07/32] refactor(ServerRequestAdapter, Request): update PHPStan return types and improve comments. --- copilot/class-documentation.md | 424 --------------------------- copilot/code-style.md | 185 ------------ copilot/general-instructions.md | 97 ------ copilot/project-documentation.md | 192 ------------ copilot/static-analysis.md | 178 ----------- copilot/unit-test.md | 179 ----------- src/adapter/ServerRequestAdapter.php | 29 +- src/http/Request.php | 3 +- 8 files changed, 15 insertions(+), 1272 deletions(-) delete mode 100644 copilot/class-documentation.md delete mode 100644 copilot/code-style.md delete mode 100644 copilot/general-instructions.md delete mode 100644 copilot/project-documentation.md delete mode 100644 copilot/static-analysis.md delete mode 100644 copilot/unit-test.md diff --git a/copilot/class-documentation.md b/copilot/class-documentation.md deleted file mode 100644 index 7ce86489..00000000 --- a/copilot/class-documentation.md +++ /dev/null @@ -1,424 +0,0 @@ -## Class Documentation Guidelines - -This guide defines PHPDoc standards for classes and methods within the PHP-Press project, also optimized for tools like -GitHub Copilot. - -## Table of Contents -1. General PHPDoc Rules. -2. Documentation Flow. -3. Class Documentation. -4. Method Documentation. -5. Property Documentation. -6. Type Hints and Templates. -7. Exception Documentation. -8. Best Practices. -9. Copilot Optimization Tips. - -## General PHPDoc Rules - -### Basic Structure -- All documentation must be in English. -- All classes, interfaces, traits, and abstract classes must have a PHPDoc block. -- All `public` and `protected` methods must have PHPDoc. -- Use complete sentences and end with a period. -- Explain **the "why"** behind design decisions. -- Keep it concise but with relevant context. -- Use proper indentation for multi-line descriptions. -- Include `@copyright` and `@license` with `{@see LICENSE}`. -- Document **only what the code actually does**. - -### Common Tags -| Tag | Purpose | -|---------------|------------------------------------------| -| `@param` | Describes each input parameter | -| `@return` | Explains the returned value | -| `@throws` | Lists exceptions thrown | -| `@template` | Declares generic templates | -| `@phpstan-var`| Specifies complex property types | - -## Documentation Flow - -1. Brief one-line description. -2. Detailed description with the "why" behind the design. -3. List of key features with bullets. -4. Practical usage examples. -5. References to related classes (`{@see}`). -6. Copyright and license. - -## Class Documentation - -### Ejemplo para Clases Regulares -path: /core/Src/Router/Route.php - -```php -/** - * Route definition with immutable configuration and matching capabilities. - * - * Represents a route with pattern matching, HTTP method restrictions, middleware support, and parameter validation. - * - * Routes are created using factory methods for specific HTTP methods and can be configured using immutable setter - * methods that return new instances with the requested changes. - * - * The matching algorithm compares incoming requests against defined route patterns, considering HTTP methods, hostname - * constraints, path patterns, query parameters, and storing matched parameters for use in actions. - * - * Key features. - * - API versioning with version prefix support (`v1`, `v2`, etc.). - * - Early validation of regex patterns during route configuration to prevent runtime errors. - * - Hostname matching with support for exact domains, wildcards, parameter capture, and regex patterns. - * - HTTP method-specific factory methods (`GET`, `POST`, `PUT`, etc.). - * - Immutable fluent interface for configuration. - * - Middleware integration for request processing. - * - Parameter validation and default values for path segments. - * - Pattern-based path matching with parameter extraction. - * - Priority-based route resolution for handling overlapping patterns. - * - Query parameter validation and extraction with regex patterns. - * - * @see \PHPPress\Router\HttpMethod for enum of valid HTTP methods. - * @see \PHPPress\Router\RouteCollection for collection, lookup, and URL generation. - * @see \PHPPress\Router\RouteParser for pattern-to-regex conversion and host matching. - * - * @copyright Copyright (C) 2023 Terabytesoftw. - * @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License. - */ -class Route -``` - -### Ejemplo Abstract class -path: /core/Src/Web/View/Base/AbstractView.php - -```php -/** - * Base class for view components providing template rendering with layouts and themes. - * - * Provides a flexible and extensible foundation for rendering view templates in web applications, supporting - * context-aware view resolution, event-driven rendering lifecycle, and pluggable renderer registration. - * - * This class enables layout wrapping, theme-based path resolution, and parameter inheritance for layouts, making it - * suitable for complex UI scenarios and modular view architectures. - * - * Views are processed through registered renderers based on file extension and can be wrapped in layouts with - * inherited parameters. - * - * The rendering process is event-driven, allowing listeners to modify content before and after rendering. - * - * Key features. - * - Context-aware view path resolution using {@see ViewContextInterface}. - * - Event-driven rendering lifecycle with before/after hooks. - * - Filesystem abstraction for view file access. - * - Flexible renderer registration for multiple file extensions. - * - Layout support with parameter inheritance and optional disabling. - * - Pluggable and extensible renderer system. - * - Theme mapping for dynamic view path resolution. - * - * @see \PHPPress\Renderer\PHPEngine for PHP renderer. - * @see \PHPPress\View\ViewContext for path context handling. - * - * @copyright Copyright (C) 2023 Terabytesoftw. - * @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License. - */ -abstract class AbstractView extends Component -``` - -### Ejemplo para Interfaces -path: /core/Src/View/ViewContextInterface.php - -```php -/** - * Interface for providing view resolution context in templates. - * - * Defines the contract for supplying directory context to view renderers, enabling them to resolve relative paths - * during template processing by referencing the base directory of the current view. - * - * This allows templates to reference other views using paths relative to their own location, supporting modular and - * maintainable UI architectures. - * - * This interface is essential for context-aware view resolution, ensuring that renderers can accurately locate and - * include related templates, layouts, or partials regardless of the rendering entry point. - * - * Key features. - * - Directory context for templates. - * - Implementation agnostic for flexible renderer integration. - * - Path normalization support for consistent resolution. - * - Supports modular and reusable view structures. - * - View path resolution for relative includes. - * - * @copyright Copyright (C) 2023 Terabytesoftw. - * @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License. - */ -interface ViewContextInterface -``` - -### Ejemplo para Excepciones -path: /core/Src/Router/Exception/RouteNotFoundException.php - -```php -/** - * Exception thrown when a route can't be found by its identifier. - * - * This exception indicates that an attempt to retrieve a named route failed because no route with the specified - * identifier exists in the route collection. It helps identify missing or misconfigured route definitions during URL - * generation. - * - * The exception uses automatic message prefixing with "Route not found:" for consistent error reporting across the - * routing system, aiding in quick identification of route definition issues. - * - * Thrown in scenarios including. - * - Dynamic route generation failures. - * - Group route resolution errors. - * - Invalid route names. - * - Missing route definitions. - * - Route collection corruption. - * - * Key features. - * - Collection state details. - * - Exception chaining support. - * - Message enum integration. - * - Route identifier tracking. - * - Route suggestion hints. - * - * @copyright Copyright (C) 2023 Terabytesoftw. - * @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License. - */ -final class RouteNotFoundException extends Exception -``` - -Enums Message for standardized error messages for Exceptions -path: /core/Src/Router/Exception/Message.php - -```php -/** - * Represents standardized error messages for router exceptions. - * - * This enum defines formatted error messages for various error conditions that may occur during router configuration - * and execution. - * - * It provides a consistent and standardized way to present error messages across the routing system. - * - * Each case represents a specific type of error, with a message template that can be populated with dynamic values - * using the {@see \PHPPress\Router\Exception\Message::getMessage()} method. - * - * This centralized approach improves the consistency of error messages and simplifies potential internationalization. - * - * Key features. - * - Centralization of an error text for easier maintenance. - * - Consistent error handling across the routing system. - * - Integration with specific exception classes. - * - Message formatting with dynamic parameters. - * - Standardized error messages for common cases. - * - * @copyright Copyright (C) 2023 Terabytesoftw. - * @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License. - */ -enum Message: string -{ - /** - * Error when host doesn't match a required pattern. - * - * Format: "The passed host '%s' of doesn't match the regexp '%s'" - */ - case HOST_NOT_MATCHED = 'The passed host \'%s\' of does not match the regexp \'%s\''; - - /** - * Returns the formatted message string for the error case. - * - * Retrieves the raw message string associated with this error case without parameter interpolation. - * - * @param string ...$argument Dynamic arguments to insert into the message. - * - * @return string Error message string with interpolated arguments. - * - * Usage example: - * ```php - * throw new RouterNotFoundException(Message::ROUTE_NOT_FOUND->getMessage('/invalid/path')); - * ``` - */ - public function getMessage(int|string ...$argument): string - { - return sprintf($this->value, ...$argument); - } -} -``` - -### Ejemplo para Tests -path: /core/tests/Router/RouteTest.php - -```php -/** - * Test suite for {@see \PHPPress\Router\Route} class functionality and behavior. - * - * Verifies routing component's ability to handle various configurations and matching scenarios. - * - * These tests ensure routing features work correctly under different conditions and maintain consistent behavior after - * code changes. - * - * The tests validate complex route matching with parameters, middleware handling, and host configuration, which are - * essential for proper HTTP request routing in the framework. - * - * Test coverage. - * - Host configuration (domain patterns, subdomain support, hostname matching). - * - HTTP method handling (normalization, case-insensitivity, method restrictions). - * - Middleware configuration (single, multiple, order preservation). - * - Parameter handling (encoded paths, URL-encoded values, parameter extraction). - * - Path matching (exact match, non-matching paths, suffix handling). - * - Pattern matching (required and optional segments, path matching). - * - Priority handling (route priority management). - * - Query parameters (required, optional, validation, type handling). - * - Route immutability and builder pattern implementation. - * - Route name validation (valid names, length constraints, complex patterns). - * - Route parameters (validation, defaults, pattern matching). - * - Suffix handling and URL normalization. - * - URL encoding (percent-encoded paths, proper decoding). - * - Versioning support (version prefixing, version mismatch handling). - * - * @see \PHPPress\Tests\Provider\Router\RouteProvider for test case data providers. - * - * @copyright Copyright (C) 2023 Terabytesoftw. - * @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License. - */ -#[Group('router')] -final class RouteTest extends TestCase -``` - -## Method Documentation - -### Constructor -```php -/** - * Creates a new instance of the {@see \PHPress\Router\RouteCollection} class. - * - * @param array $routes Initial routes to populate the collection, indexed by route name. - * - * @phpstan-param Route[] $routes - */ -``` - -### Regular Methods -```php -/** - * Retrieves all routes in the collection. - * - * Provides direct access to the full set of registered routes in their original indexed form. - * - * This method is useful for route inspection, debugging, and bulk operations on the entire route collection. - * - * @return array Array of all registered routes indexed by name. - * - * Usage example: - * ```php - * $allRoutes = $collection->all(); - * ``` - * - * @phpstan-return Route[] - */ -``` - -### Protected Methods -(Ensure that PHPDoc is as detailed as for `public` methods, especially if they influence behavior.) - -## Property Documentation - -- Avoid `@var` if the type is already declared in PHP. -- Use `@phpstan-var` for complex arrays. - -```php -/** - * A map of theme names to their corresponding paths. - * @phpstan-var array - */ -private array $themeMap = []; -``` - -## Type Hints and Templates - -```php -/** - * @template T of FilesystemAdapter - * @param array $config - * @param Collection $items - */ -``` - -```php -/** - * @param string|array $paths - * @return string[]|false - */ -``` - -## Exception Documentation (@throws) - -All @throws annotations must be written in English, use complete sentences, and follow a standardized structure for -consistency and tooling compatibility (for example, static analysis, Copilot, IDEs). - -### Structure - -```php -@throws ExceptionClass if [specific condition that causes the exception]. -``` - -- Use if clauses to describe when the exception is thrown. -- Avoid vague wording like “an error occurs...” — be clear and precise. -- Use the most specific exception available. Reserve Throwable or RuntimeException for unexpected or generic errors. -- Keep the description generic unless the method’s context requires specificity. - -### Standardized Examples - -```php -@throws BadRequestException if the request is invalid or can't be processed. -@throws EmptyStackException if the stack is empty when an element is expected. -@throws FilesystemException if the filesystem operation is invalid or fails. -@throws InvalidArgumentException if one or more arguments are invalid, of incorrect type or format. -@throws InvalidConfigException if the configuration is invalid or incomplete. -@throws InvalidDefinitionException if the definition is invalid or improperly configured. -@throws InvalidRouteParameterException if the route parameters are invalid or incomplete. -@throws MiddlewareResolutionException if the middleware handler is invalid or can't be resolved. -@throws NotInstantiableException if a class or service can't be instantiated. -@throws RouteAlreadyExistsException if a route with the same name is already registered. -@throws RouteNotFoundException if the route is not found or undefined. -@throws RuntimeException if a runtime error prevents the operation from completing successfully. -@throws TemplateNotFoundException if the template file is missing or can't be located. -@throws Throwable if an unexpected error occurs during execution. -@throws ViewNotFoundException if the view or associated renderer is missing or unavailable. -@throws WidgetStackException if the stack is empty, or if `end()` is called without a `begin()` call. -matching {@see AbstractBlock::begin()}. -``` - -## Best Practices - -### Precision -- Document only existing functionality. -- Update documentation when changing code. -- Don't document planned or future features. - -### Recommended Structure -1. Clear purpose. -2. How it is implemented. -3. List of features. -4. Usage example. -5. Cross-references. - -### Bad vs Good Examples -```php -// ❌ Incorrecto -/** Does something */ -function doSomething() {} - -// ✅ Correcto -/** - * Loads a config file and parses options. - * - * @param string $path Path to config file. - * @return array Parsed options. - */ -function loadConfig(string $path): array {} -``` - -## Copilot Optimization Tips -- Use descriptive names for functions and variables. -- Always declare input/output types. -- Keep PHPDoc updated. -- Use template annotations (`@template`, `@phpstan-*`). -- Include practical and realistic usage examples. -- Emphasize the "why" to improve model suggestions. -- Use comments within `php` blocks to explain each step. diff --git a/copilot/code-style.md b/copilot/code-style.md deleted file mode 100644 index fa56da0e..00000000 --- a/copilot/code-style.md +++ /dev/null @@ -1,185 +0,0 @@ -# Code Style Guidelines - -This guide defines coding conventions and PHP 8.2 usage for the PHP-Press project. -It complements `class-documentation.md`, `unit-test.md`, and `documentation.md` to improve readability, maintainability, -and Copilot-assisted development. - -## Table of Contents -1. PHP 8.1 Feature Usage. -2. Code Style Rules. -3. Best Practices. -4. Copilot Optimization Tips. - -## PHP 8.2 Feature Usage - -### Typed Properties with Union Types -Use explicit union types when needed: -```php -private string|int $value; -private readonly DependencyResolver $resolver; -private BundleInterface|null $bundle = null; -``` - -### Constructor Property Promotion -Prefer concise constructor definitions: -```php -public function __construct( - private readonly string $name, - private readonly int $value = 0, - private readonly array|null $config = null, -) {} -``` - -### Arrow Functions and Array Operations -Use arrow functions for transformation. Prefer `foreach` for complex logic or side effects. -```php -$transformed = array_map(static fn($x) => $x * 2, $items); -$hasNegative = array_any($items, static fn($x) => $x < 0); -``` - -### Match Expressions -Use match for explicit value checks: -```php -$result = match ($value) { - 1, 2 => 'low', - 3 => 'medium', - default => 'high', -}; -``` -Avoid `match(true)` constructs. - -### Named Arguments -Use for disambiguation only: -```php -// Good -$object->configure(options: ['cache' => true], resolver: $resolver); - -// Prefer ordered when obvious -$object->configure($resolver, ['cache' => true]); -``` - -### First-class Callable Syntax -```php -$handler = $object->handle(...); -$callback = strtolower(...); -``` - -### Enums -Document each case clearly: -```php -enum HttpMethod: string { - case GET = 'GET'; // Retrieves data - case POST = 'POST'; // Submits data - // ... -} -``` -## Code Style Rules - -### Formatting -- Line length: 120 characters. -- Indentation: 4 spaces. -- PSR-12 compliant. -- Files must end with newline. - -### Namespace & Imports -```php -declare(strict_types=1); - -namespace PHPPress\Component; - -use PHPPress\Event\EventDispatcher; - -use function array_map; -use function sprintf; -``` -Group `use function` separately after classes. - -### Class Structure -- One class per file. -- PSR-4 structure. -- Constants > Properties > Methods. -- Logical grouping. - -### Method Design -- Always declare visibility. -- Use return/parameter types. -- Prefer early returns. -- Keep short, focused methods. - -### Properties -```php -private readonly EventDispatcherInterface $dispatcher; -private array $options = ['debug' => false]; -``` -Use `string|null` instead of `?string` for consistency. - -### Type Declarations -- Strict types enabled. -- Prefer union with `null` instead of nullable (`?`). -- Use `mixed` sparingly. -- Document complex types in PHPDoc. - -### Function Imports -```php -use function array_map; -use function trim; -``` -One per line. Alphabetical order. - -### Error Handling -- Use specific exceptions. -- Use enums for messages. -- Use `??` operator for fallbacks. -- Document `@throws` in PHPDoc. - -### Arrays -```php -$config = [ - 'debug' => true, - 'cache' => false, - 'version' => '1.0.0', -]; -``` -Use short syntax. Align `=>` in multiline. - -### Control Structures -```php -if ($condition === false) { - return null; -} -``` -- Braces on same line. -- One space after control keywords. -- Use guard clauses. - -## Best Practices -- Composition over inheritance. -- Follow SOLID principles. -- Immutable objects when possible. -- Fluent interfaces for configuration. -- Clear and descriptive naming. -- Validate inputs early. - -### Immutable + Fluent Pattern -```php -$route = Route::get('users', '/users', $handler) - ->withParameters(['id' => '\d+']) - ->withDefaults(['format' => 'json']) - ->withHost('api.example.com'); -``` - -## Copilot Optimization Tips -- Keep structure and naming predictable. -- Add inline comments to show intent. -- Document "why" in PHPDoc. -- Use examples in comments or docblocks. -- Use type declarations consistently. -- Avoid overloading logic in one line. - -**Example for Copilot context:** -```php -// Create a route with constraints -$route = Route::get('profile', '/user/{id}', ProfileController::class) - ->withDefaults(['id' => '1']) - ->withParameters(['id' => '\d+']); -``` diff --git a/copilot/general-instructions.md b/copilot/general-instructions.md deleted file mode 100644 index 3ca3063f..00000000 --- a/copilot/general-instructions.md +++ /dev/null @@ -1,97 +0,0 @@ -# General Project Instructions - -This document outlines global standards for the PHP-Press project. -It complements `class-documentation.md`, `unit-test.md`, `documentation.md`, and `code-style.md` to provide a unified -foundation for team workflows, Copilot guidance, and contributor consistency. - -## Table of Contents -1. Communication -2. Language & Version -3. Code Organization -4. Quality Assurance -5. Documentation Standards -6. Contribution Workflow -7. Reference Documents - -## Communication - -- Respond to users **in Spanish**. -- Write all code and documentation **in English**. -- Ask for clarification if a request or requirement is ambiguous. -- Use clear, precise phrasing aligned with the **Microsoft Writing Style Guide**. -- When referencing filesystem paths related to the MCP command system, assume the project root is: `/core`. - -## Language & Version - -- Use **PHP 8.2** for all backend development. -- Adopt new features such as: - - Constructor property promotion. - - Property hooks. - - Arrow functions. - - Enum types. - - Array utility functions (`array_any`, `array_all`, etc.). -- See `code-style.md` for usage patterns and examples. - -## Code Organization - -- Follow **PSR-4 autoloading** and namespace structure. -- Group components by domain (for example, `Router`, `View`, `Asset`). -- Keep interfaces in `Interface/` or `Contracts/` folders. -- Structure tests to mirror the source tree inside the `/tests/` folder. -- Use the following directory conventions: - ```text - /core - /Src - /ComponentName - /tests - /ComponentName - ``` - ---- - -## Quality Assurance - -- Use **PHPUnit** for all unit tests. -- Aim for **100% test coverage**. -- Configure **PHPStan** at **level 5 or higher** (see `static-analysis.md`). -- Apply code style fixes using **ECS**. - -> Ensure every pull request includes validation for: -> - Static analysis. -> - Code style. -> - Unit tests passing. - -## Documentation Standards - -- Follow `class-documentation.md` for PHPDoc in classes, methods, and properties. -- Use `documentation.md` for non-code documentation. -- Add real-world usage examples in class-level docblocks. -- Maintain a structured and complete `CHANGELOG.md`. -- Document exceptions with `@throws` and type declarations. - -## Contribution Workflow - -- All contributors must: - - Fork from the `main` branch. - - Create topic branches per feature/fix. - - Use **conventional commits** format: - ``` - type(scope): description. - ``` - Example: `feat(router): add host validation`. - - Reference related issues via `#ID` in commits or PR descriptions. - - Include test coverage for new functionality. - - Pass all CI checks before requesting review. - ---- - -## Reference Documents - -For deeper implementation and writing standards, refer to: - -- [`class-documentation.md`](./class-documentation.md) — Class and method PHPDoc rules. -- [`unit-test.md`](./unit-test.md) — Test architecture and PHPUnit conventions. -- [`documentation.md`](./documentation.md) — General writing and formatting rules. -- [`code-style.md`](./code-style.md) — PHP code syntax and 8.2 features. -- [`static-analysis.md`](./static-analysis.md) — PHPStan config, rules, and usage. - diff --git a/copilot/project-documentation.md b/copilot/project-documentation.md deleted file mode 100644 index ce9956ee..00000000 --- a/copilot/project-documentation.md +++ /dev/null @@ -1,192 +0,0 @@ -# Documentation Guidelines - -This guide defines general documentation standards for non-code content in the PHP-Press project and is designed to work -in tandem with `class-documentation.md` and `unit-test.md`. It is optimized for clarity, maintainability, and -compatibility with GitHub Copilot. - -## Table of Contents -1. Writing Style. -2. Technical Writing. -3. Documentation Types. -4. Architecture Docs. -5. Maintenance. -6. Copilot Optimization Tips. - -## Writing Style - -### Tone and Voice -- Use a **professional but conversational** English tone. -- Write with **clarity and purpose**; prefer **active voice**. -- Address readers directly ("you"). -- Explain the **"why"** behind important guidance. - -### Microsoft Writing Style Compliance -- Use simple sentence structures (subject + verb + object). -- Keep content concise and conversational. -- Prefer contracted forms (for example, "it's", "you're"). -- Use **sentence-style capitalization**. -- Write numbers as numerals (for example, "5 users"). -- Format dates as "March 26, 2024". -- Avoid: - - Sentence fragments - - Synonyms for a single concept - - Culturally sensitive terms - - Long modifier chains - - Overly complex words - -### Formatting Guidelines -- Use headings to organize sections. -- Use whitespace for readability. -- Use bullet points for unordered lists and numbered lists for sequences. -- Keep paragraphs short (3–4 sentences). -- Include serial commas in lists. - -## Technical Writing - -### Code References -Use backticks \``\` for inline code like: - -- `composer.json` -- `DependencyResolver` -- `configure()` -- `$config` -- `PHP_VERSION` -- `['debug' => true]` - -### Code Examples -- Use fenced code blocks with language hints. -- Add context with inline comments. -- Keep examples **minimal but complete**. -- Show **input and expected output** when relevant. -- Follow a **consistent coding style**. - -**Example:** - -```php -// Configure the component with options -$component = new Component( - [ - 'debug' => true, - 'cache' => false, - ], -); - -// Returns: ['status' => 'configured'] -$result = $component->getStatus(); -``` - -### Links and References -- Use **relative links** for project docs. -- Use **descriptive link text**. -- Include version numbers for dependencies. -- Link to source code or issues when appropriate. - -## Documentation Types - -### README Files -- Project overview and purpose -- List of key features -- Quick start guide -- Installation steps -- Usage examples -- Dependencies -- License information - -### API Documentation -- Purpose and scope -- Authentication mechanism -- Request and response format -- Error structures and codes -- Rate limiting policies -- Usage examples per endpoint -- Reference to external SDKs or libraries - -### Tutorials -- Clear learning objectives -- Prerequisites list -- Step-by-step instructions -- Complete code examples -- Screenshots or expected output -- Troubleshooting tips -- References to deeper docs - -### Changelogs -- Follow **semantic versioning**. -- Group entries by type: - - Added - - Changed - - Deprecated - - Removed - - Fixed - - Security -- Provide migration notes if relevant. -- Link to related issues or PRs. - -## Architecture Docs - -### Component Documentation -- Component purpose and context -- Internal and external dependencies -- Configuration options and defaults -- Usage patterns and lifecycle -- Event triggers and listeners -- Extensibility points (hooks/plugins) -- Performance or scaling considerations - -### Integration Guides -- System/environment requirements -- Setup and configuration -- Supported platforms or stacks -- Common scenarios and walkthroughs -- Error handling and logging -- Security best practices -- Links to example projects or templates - -## Maintenance - -### Version Control -- Keep docs versioned alongside code. -- Submit doc updates as part of pull requests. -- Tag documentation per release. -- Archive obsolete versions. -- Track changes in changelogs or history. - -### Quality Checks -- Spellcheck and grammar review -- Validate links and anchors -- Run code snippets if executable -- Refresh outdated screenshots -- Test configuration instructions -- Use consistent formatting across files - -### File and Folder Organization -- Match doc folder structure to source tree -- Add index files or README in folders -- Add navigation or sidebar links -- Use version indicators where needed -- Encourage cross-referencing via `{@see}` or relative links - -### Feedback and Improvement -- Monitor issue trackers and feedback tools -- Encourage user input for unclear docs -- Update or reword misunderstood sections -- Remove or flag outdated content -- Add examples where readers get stuck - -## Copilot Optimization Tips -- Use **clear and consistent terminology**. -- Provide **working, well-commented examples**. -- Declare types when describing parameters or code structure. -- Keep each section purpose-focused (Copilot benefits from separation). -- Avoid speculative language (for example, “might do this”) and favor definitiveness. -- Include real-world usage patterns in documentation. -- Use consistent phrasing so Copilot can detect doc intent. - -**Copilot-aligned example:** - -```php -// Correct way to initialize the router -$router = new Router(); -$router->register(...); -$router->dispatch($request); -``` diff --git a/copilot/static-analysis.md b/copilot/static-analysis.md deleted file mode 100644 index 0bfa98bb..00000000 --- a/copilot/static-analysis.md +++ /dev/null @@ -1,178 +0,0 @@ -# Static Analysis Guidelines - -This document defines rules for static code analysis to ensure type safety and detect errors early in the PHP-Press project. - -## PHPStan Configuration - -### Level Settings -- Use PHPStan level 5 as the minimum requirement. -- Level 8 is mandatory for core components. -- Configure per-directory level settings as needed. - -### Base Rules -- Enable strict mode for all files. -- Require explicit method return types. -- Require explicit property types. -- Enable dead code detection. -- Validate template type constraints. - -## Type Declarations - -### Property Types -```php -/** - * @var array Registered bundles indexed by class name. - */ -private array $bundles = []; - -/** - * @var Collection CSS link tags for rendering. - */ -private array $css = []; - -/** - * @var array, array> Event listeners by event class. - */ -private array $listeners = []; -``` - -### Method Types -```php -/** - * @template T of object - * @param class-string $class - * @param array $config - * @return T - * - * @throws InvalidArgumentException - * @throws ContainerException - */ -public function create(string $class, array $config = []): object; -``` - -### Generic Types -```php -/** - * @template TKey of array-key - * @template TValue - * - * @param array $items - * @param callable(TValue): bool $predicate - * @return array - */ -public function filter(array $items, callable $predicate): array; -``` - -### Union Types -```php -/** - * @param array|string|null $paths - * @return string[] - * - * @throws InvalidArgumentException When path is invalid. - */ -public function resolvePaths(array|string|null $paths): array; -``` - -## PHPStan Baseline - -### Managing Baseline -- Generate a baseline for existing issues. -- Review and document accepted issues. -- Update the baseline with each major release. -- Track technical debt in the baseline. - -### Baseline Command -```bash -vendor/bin/phpstan analyse --generate-baseline -``` - -## Custom Rules - -### Rule Categories -- Architectural rules. -- Naming conventions. -- Type safety rules. -- Framework-specific rules. - -### Rule Implementation -```php -/** - * @implements Rule - */ -final class CustomRule implements Rule -{ - public function getNodeType(): string - { - return Node\Stmt\Class_::class; - } - - /** - * @param Node\Stmt\Class_ $node - */ - public function processNode(Node $node, Scope $scope): array - { - // Rule implementation. - } -} -``` - -## Error Categories - -### Type Safety -- Undefined methods/properties. -- Invalid argument types. -- Incompatible return types. -- Missing type declarations. -- Template type mismatches. - -### Dead Code -- Unreachable code paths. -- Unused private methods. -- Unused parameters. -- Redundant conditions. -- Dead catch blocks. - -### Method Calls -- Unknown method calls. -- Invalid argument counts. -- Type compatibility issues. -- Static call validity. -- Visibility violations. - -### Property Access -- Undefined properties. -- Invalid property types. -- Uninitialized properties. -- Readonly violations. -- Visibility checks. - -## Best Practices - -### Configuration -- Use `phpstan.neon.dist` as the base configuration. -- Include `baseline.neon`. -- Configure for the CI/CD pipeline. -- Set memory limits appropriately. -- Enable result caching. - -### Error Handling -- Document intentional suppressions. -- Use ignore patterns sparingly. -- Review suppressions regularly. -- Track technical debt items. -- Fix issues incrementally. - -### Performance -- Enable parallel analysis. -- Configure memory limits. -- Use result caching. -- Optimize ignore patterns. -- Analyze incrementally. - -### Integration -- Run in the CI/CD pipeline. -- Block merges on errors. -- Generate HTML reports. -- Track error trends. -- Review in PRs. diff --git a/copilot/unit-test.md b/copilot/unit-test.md deleted file mode 100644 index df60745c..00000000 --- a/copilot/unit-test.md +++ /dev/null @@ -1,179 +0,0 @@ -# Unit Test Guidelines - -## Table of Contents -1. Test Class Naming. -2. Test Method Naming. -3. Test Structure (Arrange-Act-Assert). -4. Data Providers. -5. Best Practices. -6. Copilot Optimization Tips. - -## Test Class Naming - -### Basic Rules -- **Clarity and Descriptiveness:** The test class name should clearly reflect the class being tested. -- **Namespace Structure:** The test class should follow the same namespace structure as the production class, - located in the `tests/` directory. -- **Naming Convention:** The class name should match the production class and end with `Test`. -- **Internal Documentation:** Document each test case and make sure to mock the necessary dependencies. - -**Example:** -```php -// Class to test in src/AbstractView/View.php -namespace PHPPress\View; - -class View { } - -// Test class in tests/AbstractView/ViewTest.php -namespace PHPPress\Tests\View; - -class ViewTest { } -``` - -## Test Method Naming - -### Recommended Pattern -Use the format `test` for test methods, where: -- **:** Describes the main action (Render, Throw, Return, etc.). -- **:** Indicates the subject under test (View, Layout, etc.). -- **:** Specifies the condition or scenario. - -**Examples:** -```php -public function testRenderLayoutWithContext(): void { } -public function testRenderViewWithTheme(): void { } -public function testThrowExceptionWhenTemplateInvalid(): void { } -``` - -### Organization -- Group and order test methods alphabetically. -- Maintain consistent nomenclature within the same test class. - -## Test Structure (Arrange-Act-Assert) - -Each test should follow the AAA pattern to ensure clarity in the intention and behavior of each test: - -```php -public function testRenderWithParametersReplacesPlaceholders(): void -{ - // Arrange: Set up the test object and necessary parameters. - $view = new View($resolver, $dispatcher); - $parameters = ['title' => 'Test']; - - // Act: Execute the main action. - $result = $view->render('index', $parameters); - - // Assert: Validate that the result meets expectations. - $this->assertStringContainsString( - 'Test', - $result, - 'Rendered view should contain the title parameter.', - ); -} -``` - -## Data Providers - -### Location and Convention -- Place data providers in the `/tests/Provider/` directory. -- Name the files and classes with a `Provider` suffix (for example, `ConverterCommandProvider`). - -### Documentation and Example -Include complete documentation in each data provider, explaining the structure and purpose of the data. - -**Example:** -path: /core/tests/Provider/Router/RouteProvider.php - -```php -namespace PHPPress\Tests\Provider\Router; - -/** - * Data provider for testing the Router component. - * - * Designed to ensure the router component correctly processes all supported configurations and appropriately handles - * edge cases with proper error messaging. - * - * The test data validates complex route configuration scenarios including security-sensitive inputs to prevent - * potential injection vulnerabilities. - * - * The provider organizes test cases with descriptive names for quick identification of failure cases during test - * execution and debugging sessions. - * - * Key features. - * - Comprehensive test cases for all router features. - * - Edge case testing for input validation. - * - Host pattern matching with domain validation. - * - Named test data sets for clear failure identification. - * - Parameter pattern validation with regular expressions. - * - Security scenario testing for potential injection patterns. - * - URL suffix and pattern matching validation. - * - * @copyright Copyright (C) 2024 PHPPress. - * @license https://opensource.org/license/gpl-3-0 GNU General Public License version 3 or later. - */ -final class RouteProvider -``` - -**Usage in Tests:** -path: /core/tests/Router/RouteTest.php - -```php -#[DataProviderExternal(RouteProvider::class, 'names')] -public function testAcceptRouteNamesWhenValid(string $name): void -{ - $route = Route::to($name, '/test', FactoryHelper::createHandler()); - - $this->assertSame($name, $route->name, "Route should accept valid route name: '{$name}'."); -} -``` - -## Best Practices - -### General Principles -- **Independence:** Each test should be independent and not depend on the execution order. -- **Cleanup:** Use `setUp()` and `tearDown()` to clean up global or static states between tests. -- **Complete Coverage:** Test both positive and negative cases, including edge values and exception scenarios. -- **Clear Messages:** Assertion messages should explain what was expected and what was obtained, making it easier to - identify failures. - -**Example of Detailed Assertion:** -```php -$this->assertSame( - $expected, - $actual, - 'Route should match when URL parameters meet the expected pattern with multiple parameters.' -); -$this->assertTrue( - $this->filesystem->fileExists("{$this->dirFrom}/js/script.js"), - 'File \'js/script.js\' should exist in the destination after excluding only the strict subdirectory legacy CSS.', -); -$this->assertTrue( - $this->filesystem->copyMatching($this->dir, $this->dirFrom), - 'Copy matching should return \'true\' when copying all files recursively without patterns.', -); -``` - -### Exceptions in Tests -- Document in the PHPDoc the conditions under which an exception is expected to be thrown, including clear examples. - -**Example:** -path: /core/tests/Router/RouteTest.php - -```php -public function testThrowExceptionWithInvalidVersion(): void -{ - $this->expectException(InvalidRouteParameterException::class); - $this->expectExceptionMessage(Message::INVALID_ROUTE_VERSION->getMessage('invalid')); - - Route::get('users', '/users', FactoryHelper::createHandler())->withVersion('invalid'); -} -``` - -## Copilot Optimization Tips - -- **Clear and Descriptive Names:** Use meaningful names for methods and variables. -- **Type Declaration:** Always specify parameter and return types in your test methods. -- **Updated Documentation:** Keep PHPDoc updated with clear and detailed examples. -- **Code Comments:** Add brief comments explaining each part of the AAA structure in your tests. -- **Explanation of "Why":** Include in the documentation the rationale behind each test so that tools - like GitHub Copilot can generate suggestions aligned with the design. diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index fc9431d1..b6bd8f26 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -7,6 +7,7 @@ use Psr\Http\Message\{ServerRequestInterface, UploadedFileInterface}; use Yii; use yii\base\InvalidConfigException; +use yii\helpers\Json; use yii\web\{Cookie, HeaderCollection}; final class ServerRequestAdapter @@ -20,7 +21,7 @@ public function getBodyParams(string $methodParam): array|object { $parsedBody = $this->psrRequest->getParsedBody(); - // Remove method parameter if present (same logic as parent) + // remove method parameter if present (same logic as parent) if (is_array($parsedBody) && isset($parsedBody[$methodParam])) { $bodyParams = $parsedBody; @@ -33,11 +34,10 @@ public function getBodyParams(string $methodParam): array|object } /** - * @phpstan-return array + * @phpstan-return array */ public function getCookieParams(): array { - // @phpstan-ignore return.type return $this->psrRequest->getCookieParams(); } @@ -62,7 +62,7 @@ public function getCookies(bool $enableValidation = false, string $validationKey continue; } - $data = @unserialize($data); + $data = Json::decode($data, true); if (is_array($data) && isset($data[0], $data[1]) && $data[0] === $name) { $cookies[$name] = new Cookie( @@ -114,7 +114,7 @@ public function getMethodWithOverride(string $methodParam = '_method'): string { $parsedBody = $this->psrRequest->getParsedBody(); - // Check for method override in body + // check for method override in body if ( is_array($parsedBody) && isset($parsedBody[$methodParam]) && @@ -127,31 +127,31 @@ public function getMethodWithOverride(string $methodParam = '_method'): string } } - // Check for X-Http-Method-Override header - $headers = $this->psrRequest->getHeaders(); + // check for X-Http-Method-Override header + if ($this->psrRequest->hasHeader('X-Http-Method-Override')) { + $overrideHeader = $this->psrRequest->getHeaderLine('X-Http-Method-Override'); - if (isset($headers['X-Http-Method-Override'], $headers['X-Http-Method-Override'][0])) { - return strtoupper($headers['X-Http-Method-Override'][0]); + if ($overrideHeader !== '') { + return strtoupper($overrideHeader); + } } return $this->psrRequest->getMethod(); } /** - * @phpstan-return array|object|null + * @phpstan-return array|object|null */ public function getParsedBody(): array|object|null { - // @phpstan-ignore return.type return $this->psrRequest->getParsedBody(); } /** - * @phpstan-return array + * @phpstan-return array */ public function getQueryParams(): array { - // @phpstan-ignore return.type return $this->psrRequest->getQueryParams(); } @@ -183,11 +183,10 @@ public function getScriptUrl(): string } /** - * @phpstan-return array + * @phpstan-return array */ public function getServerParams(): array { - // @phpstan-ignore return.type return $this->psrRequest->getServerParams(); } diff --git a/src/http/Request.php b/src/http/Request.php index 19695c0c..75772f8c 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -81,7 +81,7 @@ public function getPsr7Request(): ServerRequestInterface } /** - * @phpstan-return array + * @phpstan-return array */ public function getQueryParams(): array { @@ -89,7 +89,6 @@ public function getQueryParams(): array return $this->adapter->getQueryParams(); } - // @phpstan-ignore return.type return parent::getQueryParams(); } From 88d0823d8a0c06c0fa484dd5a66ffe3a66314955 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 19 Jul 2025 14:38:17 -0400 Subject: [PATCH 08/32] Remove unused files: delete 'Contents', 'Page not found', and 'phpstan.neon' configuration.. --- Contents | 1 - Page not found | 1 - phpstan.neon | 24 ------------------------ 3 files changed, 26 deletions(-) delete mode 100644 Contents delete mode 100644 Page not found delete mode 100644 phpstan.neon diff --git a/Contents b/Contents deleted file mode 100644 index 9ae9ea0a..00000000 --- a/Contents +++ /dev/null @@ -1 +0,0 @@ -Contents \ No newline at end of file diff --git a/Page not found b/Page not found deleted file mode 100644 index 98b8502d..00000000 --- a/Page not found +++ /dev/null @@ -1 +0,0 @@ -Page not found \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon deleted file mode 100644 index 735031cc..00000000 --- a/phpstan.neon +++ /dev/null @@ -1,24 +0,0 @@ -includes: - - phar://phpstan.phar/conf/bleedingEdge.neon - -parameters: - bootstrapFiles: - - tests/bootstrap.php - - level: max - - paths: - - src - - tests - - tmpDir: %currentWorkingDirectory%/runtime - - # Enable strict advanced checks - checkImplicitMixed: true - checkBenevolentUnionTypes: true - checkUninitializedProperties: true - checkMissingCallableSignature: true - checkTooWideReturnTypesInProtectedAndPublicMethods: true - reportAnyTypeWideningInVarTag: true - reportPossiblyNonexistentConstantArrayOffset: true - reportPossiblyNonexistentGeneralArrayOffset: true From 59ae7702f9a4a60ba8d3b76a05e2c6f44b9ae0b0 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 19 Jul 2025 14:40:35 -0400 Subject: [PATCH 09/32] Add phpstan.neon configuration for static analysis. --- This content should not be emitted | 1 - phpstan.neon | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) delete mode 100644 This content should not be emitted create mode 100644 phpstan.neon diff --git a/This content should not be emitted b/This content should not be emitted deleted file mode 100644 index 2d54e228..00000000 --- a/This content should not be emitted +++ /dev/null @@ -1 +0,0 @@ -This content should not be emitted \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..63c0f318 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,21 @@ +includes: + - phar://phpstan.phar/conf/bleedingEdge.neon + +parameters: + level: max + + paths: + - src + - tests + + tmpDir: %currentWorkingDirectory%/runtime + + # Enable strict advanced checks + checkImplicitMixed: true + checkBenevolentUnionTypes: true + checkUninitializedProperties: true + checkMissingCallableSignature: true + checkTooWideReturnTypesInProtectedAndPublicMethods: true + reportAnyTypeWideningInVarTag: true + reportPossiblyNonexistentConstantArrayOffset: true + reportPossiblyNonexistentGeneralArrayOffset: true From 7c3f7388ec3acb9c46213eae32679e62798d4139 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 19 Jul 2025 14:43:57 -0400 Subject: [PATCH 10/32] Add bootstrapFiles parameter to phpstan.neon for test initialization. --- phpstan.neon | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phpstan.neon b/phpstan.neon index 63c0f318..735031cc 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,9 @@ includes: - phar://phpstan.phar/conf/bleedingEdge.neon parameters: + bootstrapFiles: + - tests/bootstrap.php + level: max paths: From 2b24ad3a666dec55215c9847e2250a910d6fbb3c Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 19 Jul 2025 15:19:03 -0400 Subject: [PATCH 11/32] refactor(Request, ServerRequestAdapter): pass workerMode to getScriptUrl method. --- src/adapter/ServerRequestAdapter.php | 4 ++-- src/http/Request.php | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index b6bd8f26..a69834f6 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -168,12 +168,12 @@ public function getRawBody(): string return $body->getContents(); } - public function getScriptUrl(): string + public function getScriptUrl(bool $workerMode): string { $serverParams = $this->psrRequest->getServerParams(); // for traditional PSR-7 apps where SCRIPT_NAME is available - if (isset($serverParams['SCRIPT_NAME']) && is_string($serverParams['SCRIPT_NAME'])) { + if ($workerMode === false && isset($serverParams['SCRIPT_NAME']) && is_string($serverParams['SCRIPT_NAME'])) { return $serverParams['SCRIPT_NAME']; } diff --git a/src/http/Request.php b/src/http/Request.php index 75772f8c..09fb8b19 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -13,6 +13,7 @@ final class Request extends \yii\web\Request { private CookieCollection|null $_cookies = null; private ServerRequestAdapter|null $adapter = null; + private bool $workerMode = true; /** * @phpstan-return array|object @@ -113,7 +114,7 @@ public function getRawBody(): string public function getScriptUrl(): string { if ($this->adapter !== null) { - return $this->adapter->getScriptUrl(); + return $this->adapter->getScriptUrl($this->workerMode); } return parent::getScriptUrl(); From 531880529f4d7f31f7bf2140fc8e3daeb89bf5a6 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 04:51:56 -0400 Subject: [PATCH 12/32] refactor(Request, ServerRequestAdapter): simplify cookie handling and add unit tests. --- src/adapter/ServerRequestAdapter.php | 112 +++++--- src/http/Request.php | 20 +- tests/http/PSR7RequestTest.php | 412 +++++++++++++++++++++++++++ 3 files changed, 483 insertions(+), 61 deletions(-) create mode 100644 tests/http/PSR7RequestTest.php diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index a69834f6..7095f3e8 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -46,52 +46,9 @@ public function getCookieParams(): array */ public function getCookies(bool $enableValidation = false, string $validationKey = ''): array { - $cookies = []; - $cookieParams = $this->psrRequest->getCookieParams(); - - if ($enableValidation) { - if ($validationKey === '') { - throw new InvalidConfigException('Cookie validation key must be provided.'); - } - - foreach ($cookieParams as $name => $value) { - if (is_string($value) && $value !== '') { - $data = Yii::$app->getSecurity()->validateData($value, $validationKey); - - if (is_string($data) === false) { - continue; - } - - $data = Json::decode($data, true); - - if (is_array($data) && isset($data[0], $data[1]) && $data[0] === $name) { - $cookies[$name] = new Cookie( - [ - 'name' => $name, - 'value' => $data[1], - 'expire' => null, - ], - ); - } - } - } - } else { - foreach ($cookieParams as $name => $value) { - if ($value === '') { - continue; - } - - $cookies[$name] = new Cookie( - [ - 'name' => $name, - 'value' => $value, - 'expire' => null, - ], - ); - } - } - - return $cookies; + return $enableValidation + ? $this->getValidatedCookies($validationKey) + : $this->getSimpleCookies(); } public function getHeaders(): HeaderCollection @@ -253,6 +210,69 @@ private function convertSingleFile(UploadedFileInterface $file): array ]; } + /** + * @phpstan-return array + */ + private function getSimpleCookies(): array + { + $cookies = []; + $cookieParams = $this->psrRequest->getCookieParams(); + + foreach ($cookieParams as $name => $value) { + if ($value !== '') { + $cookies[$name] = new Cookie( + [ + 'name' => $name, + 'value' => $value, + 'expire' => null, + ], + ); + } + } + + return $cookies; + } + + /** + * @phpstan-return array + */ + private function getValidatedCookies(string $validationKey): array + { + if ($validationKey === '') { + throw new InvalidConfigException('Cookie validation key must be provided.'); + } + + $cookies = []; + $cookieParams = $this->psrRequest->getCookieParams(); + + foreach ($cookieParams as $name => $value) { + if (is_string($value) && $value !== '') { + $data = Yii::$app->getSecurity()->validateData($value, $validationKey); + + if (is_string($data) === false) { + continue; + } + + $decodedData = Json::decode($data, true); + + if (is_array($decodedData) && + isset($decodedData[0], $decodedData[1]) && + $decodedData[0] === $name) { + + $cookies[$name] = new Cookie( + [ + 'name' => $name, + 'value' => $decodedData[1], + 'expire' => null, + ], + ); + } + } + } + + return $cookies; + } + /** * @phpstan-param array $fileArray * diff --git a/src/http/Request.php b/src/http/Request.php index 09fb8b19..479923cf 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -11,7 +11,6 @@ final class Request extends \yii\web\Request { - private CookieCollection|null $_cookies = null; private ServerRequestAdapter|null $adapter = null; private bool $workerMode = true; @@ -27,17 +26,15 @@ public function getBodyParams(): array|object return parent::getBodyParams(); } - public function getCookies() + public function getCookies(): CookieCollection { - if ($this->_cookies === null) { - $cookies = $this->adapter !== null - ? $this->adapter->getCookies($this->enableCookieValidation, $this->cookieValidationKey) - : parent::loadCookies(); + if ($this->adapter !== null) { + $cookies = $this->adapter->getCookies($this->enableCookieValidation, $this->cookieValidationKey); - $this->_cookies = new CookieCollection($cookies, ['readOnly' => true]); + return new CookieCollection($cookies, ['readOnly' => true]); } - return $this->_cookies; + return parent::getCookies(); } public function getCsrfTokenFromHeader(): string|null @@ -134,18 +131,11 @@ public function getUrl(): string return $this->getAdapter()->getUrl(); } - /** - * Reset para workers - */ public function reset(): void { $this->adapter = null; - $this->_cookies = null; } - /** - * Establece la request PSR-7 - */ public function setPsr7Request(ServerRequestInterface $request): void { $this->adapter = new ServerRequestAdapter($request); diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php new file mode 100644 index 00000000..a89f744c --- /dev/null +++ b/tests/http/PSR7RequestTest.php @@ -0,0 +1,412 @@ +mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + + $psr7Request = $psr7Request->withCookieParams(['reset_cookie' => 'test_value']); + + $request = new Request(); + + $request->enableCookieValidation = false; + $request->cookieValidationKey = 'test-validation-key-32-characters'; + + $request->setPsr7Request($psr7Request); + $cookies1 = $request->getCookies(); + + $request->reset(); + + $newPsr7Request = FactoryHelper::createRequest('GET', '/test'); + + $newPsr7Request = $newPsr7Request->withCookieParams(['new_cookie' => 'new_value']); + $request->setPsr7Request($newPsr7Request); + $cookies2 = $request->getCookies(); + + self::assertNotSame( + $cookies1, + $cookies2, + "After reset, 'getCookies()' should return a new 'CookieCollection' instance.", + ); + self::assertTrue( + $cookies2->has('new_cookie'), + "New 'CookieCollection' should contain 'new_cookie' after reset.", + ); + self::assertSame( + 'new_value', + $cookies2->getValue('new_cookie'), + "New cookie 'new_cookie' should have the expected value after reset.", + ); + } + public function testReturnBodyParamsWhenPsr7RequestHasFormData(): void + { + $psr7Request = FactoryHelper::createRequest( + 'POST', + '/test', + ['Content-Type' => 'application/x-www-form-urlencoded'], + [ + 'key1' => 'value1', + 'key2' => 'value2', + ], + ); + + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $bodyParams = $request->getBodyParams(); + + self::assertIsArray( + $bodyParams, + "Body parameters should be returned as an array when 'PSR-7' request contains form data.", + ); + self::assertArrayHasKey( + 'key1', + $bodyParams, + "Body parameters should contain the key 'key1' when present in the 'PSR-7' request.", + ); + self::assertSame( + 'value1', + $bodyParams['key1'] ?? null, + "Body parameter 'key1' should have the expected value from the 'PSR-7' request.", + ); + self::assertArrayHasKey( + 'key2', + $bodyParams, + "Body parameters should contain the key 'key2' when present in the 'PSR-7' request.", + ); + self::assertSame( + 'value2', + $bodyParams['key2'] ?? null, + "Body parameter 'key2' should have the expected value from the 'PSR-7' request.", + ); + } + + public function testReturnBodyParamsWithMethodParamRemoved(): void + { + $psr7Request = FactoryHelper::createRequest( + 'POST', + '/test', + ['Content-Type' => 'application/x-www-form-urlencoded'], + [ + 'key1' => 'value1', + 'key2' => 'value2', + '_method' => 'PUT', + ], + ); + + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $bodyParams = $request->getBodyParams(); + + self::assertIsArray( + $bodyParams, + 'Body parameters should be returned as an array when method parameter is present.', + ); + self::assertArrayNotHasKey( + '_method', + $bodyParams, + "Method parameter '_method' should be removed from body parameters.", + ); + self::assertArrayHasKey( + 'key1', + $bodyParams, + "Body parameters should contain the key 'key1' after method parameter removal.", + ); + self::assertSame( + 'value1', + $bodyParams['key1'] ?? null, + "Body parameter 'key1' should have the expected value after method parameter removal.", + ); + self::assertArrayHasKey( + 'key2', + $bodyParams, + "Body parameters should contain the key 'key2' after method parameter removal.", + ); + self::assertSame( + 'value2', + $bodyParams['key2'] ?? null, + "Body parameter 'key2' should have the expected value after method parameter removal.", + ); + } + + public function testReturnCookieCollectionWhenCookiesPresent(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + + $psr7Request = $psr7Request->withCookieParams( + [ + 'session_id' => 'abc123', + 'theme' => 'dark', + 'empty_cookie' => '', + ], + ); + + $request = new Request(); + + $request->enableCookieValidation = false; + $request->cookieValidationKey = 'test-validation-key-32-characters'; + + $request->setPsr7Request($psr7Request); + $cookies = $request->getCookies(); + + self::assertInstanceOf( + CookieCollection::class, + $cookies, + "Cookies should return a 'CookieCollection' instance when cookies are present.", + ); + self::assertCount( + 2, + $cookies, + "'CookieCollection' should contain '2' cookies when empty cookies are filtered out.", + ); + self::assertTrue( + $cookies->has('session_id'), + "'CookieCollection' should contain 'session_id' cookie.", + ); + self::assertSame( + 'abc123', + $cookies->getValue('session_id'), + "Cookie 'session_id' should have the expected value from the 'PSR-7' request.", + ); + self::assertTrue( + $cookies->has('theme'), + "'CookieCollection' should contain 'theme' cookie.", + ); + self::assertSame( + 'dark', + $cookies->getValue('theme'), + "Cookie 'theme' should have the expected value from the 'PSR-7' request.", + ); + self::assertFalse( + $cookies->has('empty_cookie'), + "'CookieCollection' should not contain empty cookies.", + ); + } + + public function testReturnCookieCollectionWhenNoCookiesPresent(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + + $request = new Request(); + + $request->enableCookieValidation = false; + $request->cookieValidationKey = 'test-validation-key-32-characters'; + + $request->setPsr7Request($psr7Request); + $cookies = $request->getCookies(); + + self::assertInstanceOf( + CookieCollection::class, + $cookies, + "Cookies should return a 'CookieCollection' instance when no cookies are present.", + ); + self::assertCount( + 0, + $cookies, + "'CookieCollection' should be empty when no cookies are present in the 'PSR-7' request.", + ); + } + + public function testReturnCookieCollectionWithValidationDisabled(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + + $psr7Request = $psr7Request->withCookieParams( + [ + 'user_id' => '42', + 'preferences' => 'compact', + ], + ); + + $request = new Request(); + + $request->enableCookieValidation = false; + $request->cookieValidationKey = 'test-validation-key-32-characters'; + + $request->setPsr7Request($psr7Request); + $cookies = $request->getCookies(); + + self::assertInstanceOf( + CookieCollection::class, + $cookies, + "Cookies should return a 'CookieCollection' instance when validation is disabled.", + ); + self::assertCount( + 2, + $cookies, + "'CookieCollection' should contain all non-empty cookies when validation is disabled.", + ); + self::assertTrue( + $cookies->has('user_id'), + "'CookieCollection' should contain 'user_id' cookie when validation is disabled.", + ); + self::assertSame( + '42', + $cookies->getValue('user_id'), + "Cookie 'user_id' should have the expected value when validation is disabled.", + ); + self::assertTrue( + $cookies->has('preferences'), + "'CookieCollection' should contain 'preferences' cookie when validation is disabled.", + ); + self::assertSame( + 'compact', + $cookies->getValue('preferences'), + "Cookie 'preferences' should have the expected value when validation is disabled.", + ); + } + + public function testReturnEmptyCookieCollectionWhenValidationEnabledButNoValidationKey(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + + $psr7Request = $psr7Request->withCookieParams(['session_id' => 'abc123']); + + $request = new Request(); + + $request->enableCookieValidation = true; + $request->cookieValidationKey = ''; + + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Cookie validation key must be provided.'); + + $request->setPsr7Request($psr7Request); + $request->getCookies(); + } + + public function testReturnEmptyCookieCollectionWhenValidationEnabledWithInvalidCookies(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + + $psr7Request = $psr7Request->withCookieParams( + [ + 'invalid_cookie' => 'invalid_data', + 'empty_cookie' => '', + ], + ); + + $request = new Request(); + + $request->enableCookieValidation = true; + $request->cookieValidationKey = 'test-validation-key-32-characters'; + + $request->setPsr7Request($psr7Request); + $cookies = $request->getCookies(); + + self::assertInstanceOf( + CookieCollection::class, + $cookies, + "Cookies should return a 'CookieCollection' instance when validation is enabled with invalid cookies.", + ); + self::assertCount( + 0, + $cookies, + "'CookieCollection' should be empty when validation is enabled but cookies are invalid.", + ); + } + + public function testReturnNewCookieCollectionInstanceOnEachCall(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + + $psr7Request = $psr7Request->withCookieParams(['cached_cookie' => 'test_value']); + + $request = new Request(); + + $request->enableCookieValidation = false; + $request->cookieValidationKey = 'test-validation-key-32-characters'; + + $request->setPsr7Request($psr7Request); + $cookies1 = $request->getCookies(); + $cookies2 = $request->getCookies(); + + self::assertNotSame( + $cookies1, + $cookies2, + "Each call to 'getCookies()' should return a new 'CookieCollection' instance, not a cached one.", + ); + } + + public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies(): void + { + $this->mockWebApplication(); + + $validationKey = 'test-validation-key-32-characters'; + + // create a valid signed cookie using Yii security component + $cookieName = 'valid_session'; + $cookieValue = 'abc123session'; + $data = [$cookieName, $cookieValue]; + + $signedCookieValue = Yii::$app->getSecurity()->hashData(\yii\helpers\Json::encode($data), $validationKey); + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + + $psr7Request = $psr7Request->withCookieParams( + [ + $cookieName => $signedCookieValue, + 'invalid_cookie' => 'invalid_data', + ], + ); + + $request = new Request(); + + $request->enableCookieValidation = true; + $request->cookieValidationKey = $validationKey; + + $request->setPsr7Request($psr7Request); + $cookies = $request->getCookies(); + + self::assertInstanceOf( + CookieCollection::class, + $cookies, + "Cookies should return a 'CookieCollection' instance when validation is enabled with valid cookies.", + ); + self::assertCount( + 1, + $cookies, + "'CookieCollection' should contain only the valid signed cookie when validation is enabled.", + ); + self::assertTrue( + $cookies->has($cookieName), + "'CookieCollection' should contain the valid signed cookie '{$cookieName}'.", + ); + self::assertSame( + $cookieValue, + $cookies->getValue($cookieName), + "Valid signed cookie '{$cookieName}' should have the expected decrypted value.", + ); + self::assertFalse( + $cookies->has('invalid_cookie'), + "'CookieCollection' should not contain invalid cookies when validation is enabled.", + ); + } +} From ed38a3c4d99129972cde1c5db59f35ddb7c237b5 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sun, 20 Jul 2025 08:52:36 +0000 Subject: [PATCH 13/32] Apply fixes from StyleCI --- src/adapter/ServerRequestAdapter.php | 1 - tests/http/PSR7RequestTest.php | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index 7095f3e8..e1ca0f43 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -258,7 +258,6 @@ private function getValidatedCookies(string $validationKey): array if (is_array($decodedData) && isset($decodedData[0], $decodedData[1]) && $decodedData[0] === $name) { - $cookies[$name] = new Cookie( [ 'name' => $name, diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index a89f744c..a1dce81e 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -54,6 +54,7 @@ public function testResetCookieCollectionAfterReset(): void "New cookie 'new_cookie' should have the expected value after reset.", ); } + public function testReturnBodyParamsWhenPsr7RequestHasFormData(): void { $psr7Request = FactoryHelper::createRequest( @@ -367,7 +368,7 @@ public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies( $cookieValue = 'abc123session'; $data = [$cookieName, $cookieValue]; - $signedCookieValue = Yii::$app->getSecurity()->hashData(\yii\helpers\Json::encode($data), $validationKey); + $signedCookieValue = Yii::$app->getSecurity()->hashData(Yii\helpers\Json::encode($data), $validationKey); $psr7Request = FactoryHelper::createRequest('GET', '/test'); $psr7Request = $psr7Request->withCookieParams( From 44b0add432fa458e4d62d8c5ed3270040feb8417 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 05:02:20 -0400 Subject: [PATCH 14/32] fix(PSR7RequestTest): use consistent Json import for cookie signing. --- tests/http/PSR7RequestTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index a1dce81e..e6f248cf 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -11,6 +11,7 @@ use yii2\extensions\psrbridge\http\Request; use yii2\extensions\psrbridge\tests\support\FactoryHelper; use yii2\extensions\psrbridge\tests\TestCase; +use yii\helpers\Json; #[Group('http')] final class PSR7RequestTest extends TestCase @@ -368,7 +369,7 @@ public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies( $cookieValue = 'abc123session'; $data = [$cookieName, $cookieValue]; - $signedCookieValue = Yii::$app->getSecurity()->hashData(Yii\helpers\Json::encode($data), $validationKey); + $signedCookieValue = Yii::$app->getSecurity()->hashData(Json::encode($data), $validationKey); $psr7Request = FactoryHelper::createRequest('GET', '/test'); $psr7Request = $psr7Request->withCookieParams( From 31143ffb01cf27e621528f23a5c47e99e754ee70 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 05:04:24 -0400 Subject: [PATCH 15/32] fix(PSR7RequestTest): reorder Json import for consistency. --- tests/http/PSR7RequestTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index e6f248cf..e7bccaf3 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -7,11 +7,11 @@ use PHPUnit\Framework\Attributes\Group; use Yii; use yii\base\InvalidConfigException; +use yii\helpers\Json; use yii\web\CookieCollection; use yii2\extensions\psrbridge\http\Request; use yii2\extensions\psrbridge\tests\support\FactoryHelper; use yii2\extensions\psrbridge\tests\TestCase; -use yii\helpers\Json; #[Group('http')] final class PSR7RequestTest extends TestCase From 19bf30adf1938027787bc22e4ebf4da7c6772f0c Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 05:20:46 -0400 Subject: [PATCH 16/32] feat(PSR7RequestTest): add CSRF token handling tests for header retrieval. --- tests/http/PSR7RequestTest.php | 106 ++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index e7bccaf3..2fd99c3f 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -67,7 +67,6 @@ public function testReturnBodyParamsWhenPsr7RequestHasFormData(): void 'key2' => 'value2', ], ); - $request = new Request(); $request->setPsr7Request($psr7Request); @@ -111,7 +110,6 @@ public function testReturnBodyParamsWithMethodParamRemoved(): void '_method' => 'PUT', ], ); - $request = new Request(); $request->setPsr7Request($psr7Request); @@ -209,7 +207,6 @@ public function testReturnCookieCollectionWhenNoCookiesPresent(): void $this->mockWebApplication(); $psr7Request = FactoryHelper::createRequest('GET', '/test'); - $request = new Request(); $request->enableCookieValidation = false; @@ -281,6 +278,55 @@ public function testReturnCookieCollectionWithValidationDisabled(): void ); } + public function testReturnCsrfTokenFromHeaderWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $csrfToken = 'test-csrf-token-value'; + + $psr7Request = FactoryHelper::createRequest( + 'POST', + '/test', + ['X-CSRF-Token' => $csrfToken], + ); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $result = $request->getCsrfTokenFromHeader(); + + self::assertSame( + $csrfToken, + $result, + "'CSRF' token from header should match the value provided in the 'PSR-7' request header 'X-CSRF-Token'.", + ); + } + + public function testReturnCsrfTokenFromHeaderWithCustomHeaderWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $customHeaderName = 'X-Custom-CSRF'; + $csrfToken = 'custom-csrf-token-value'; + + $psr7Request = FactoryHelper::createRequest( + 'PUT', + '/api/resource', + [$customHeaderName => $csrfToken], + ); + $request = new Request(); + + $request->csrfHeader = $customHeaderName; + + $request->setPsr7Request($psr7Request); + $result = $request->getCsrfTokenFromHeader(); + + self::assertSame( + $csrfToken, + $result, + "'CSRF' token from header should match the value provided in the custom 'PSR-7' request header.", + ); + } + public function testReturnEmptyCookieCollectionWhenValidationEnabledButNoValidationKey(): void { $this->mockWebApplication(); @@ -358,6 +404,60 @@ public function testReturnNewCookieCollectionInstanceOnEachCall(): void ); } + public function testReturnNullFromHeaderWhenCsrfHeaderEmptyAndAdapterIsSet(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest( + 'PATCH', + '/api/update', + ['X-CSRF-Token' => ''], + ); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $result = $request->getCsrfTokenFromHeader(); + + self::assertSame( + '', + $result, + "'CSRF' token from header should return empty string when 'CSRF' header is present but empty in the " . + "'PSR-7' request.", + ); + } + + public function testReturnNullFromHeaderWhenCsrfHeaderNotPresentAndAdapterIsSet(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('DELETE', '/api/resource'); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $result = $request->getCsrfTokenFromHeader(); + + self::assertNull( + $result, + "'CSRF' token from header should return 'null' when no 'CSRF' header is present in the 'PSR-7' request.", + ); + } + + public function testReturnParentCsrfTokenFromHeaderWhenAdapterIsNull(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + // ensure adapter is null (default state) + $request->reset(); + $result = $request->getCsrfTokenFromHeader(); + + self::assertNull( + $result, + "'CSRF' token from header should return parent implementation result when adapter is 'null'.", + ); + } + public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies(): void { $this->mockWebApplication(); From 663af6a8cb7b2667638f4912634b6be9d5a5be40 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 05:35:20 -0400 Subject: [PATCH 17/32] refactor(ServerRequestAdapter, Request): unify method retrieval and enhance tests for method overrides. --- src/adapter/ServerRequestAdapter.php | 9 +- src/http/Request.php | 2 +- tests/http/PSR7RequestTest.php | 123 +++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index e1ca0f43..991f45c4 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -62,12 +62,7 @@ public function getHeaders(): HeaderCollection return $headerCollection; } - public function getMethod(): string - { - return $this->psrRequest->getMethod(); - } - - public function getMethodWithOverride(string $methodParam = '_method'): string + public function getMethod(string $methodParam = '_method'): string { $parsedBody = $this->psrRequest->getParsedBody(); @@ -84,7 +79,7 @@ public function getMethodWithOverride(string $methodParam = '_method'): string } } - // check for X-Http-Method-Override header + // check for 'X-Http-Method-Override' header if ($this->psrRequest->hasHeader('X-Http-Method-Override')) { $overrideHeader = $this->psrRequest->getHeaderLine('X-Http-Method-Override'); diff --git a/src/http/Request.php b/src/http/Request.php index 479923cf..c875be35 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -62,7 +62,7 @@ public function getHeaders(): HeaderCollection public function getMethod(): string { if ($this->adapter !== null) { - return $this->adapter->getMethodWithOverride($this->methodParam); + return $this->adapter->getMethod($this->methodParam); } return parent::getMethod(); diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index 2fd99c3f..bbcf01bf 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -380,6 +380,113 @@ public function testReturnEmptyCookieCollectionWhenValidationEnabledWithInvalidC ); } + public function testReturnHttpMethodFromAdapterWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('POST', '/test'); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $method = $request->getMethod(); + + self::assertSame( + 'POST', + $method, + 'HTTP method should be returned from adapter when adapter is set.', + ); + } + + public function testReturnHttpMethodWithBodyOverrideWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest( + 'POST', + '/test', + ['Content-Type' => 'application/x-www-form-urlencoded'], + [ + '_method' => 'PUT', + 'data' => 'value', + ], + ); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $method = $request->getMethod(); + + self::assertSame( + 'PUT', + $method, + 'HTTP method should be overridden by body parameter when adapter is set.', + ); + } + + public function testReturnHttpMethodWithCustomMethodParamWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest( + 'POST', + '/test', + ['Content-Type' => 'application/x-www-form-urlencoded'], + [ + 'custom_method' => 'PATCH', + 'data' => 'value', + ], + ); + $request = new Request(); + + $request->methodParam = 'custom_method'; + + $request->setPsr7Request($psr7Request); + $method = $request->getMethod(); + + self::assertSame( + 'PATCH', + $method, + 'HTTP method should be overridden by custom method parameter when adapter is set.', + ); + } + + public function testReturnHttpMethodWithHeaderOverrideWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest( + 'POST', + '/test', + ['X-Http-Method-Override' => 'DELETE'], + ); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $method = $request->getMethod(); + + self::assertSame( + 'DELETE', + $method, + 'HTTP method should be overridden by header when adapter is set.', + ); + } + + public function testReturnHttpMethodWithoutOverrideWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $method = $request->getMethod(); + + self::assertSame( + 'GET', + $method, + 'HTTP method should return original method when no override is present and adapter is set.', + ); + } + public function testReturnNewCookieCollectionInstanceOnEachCall(): void { $this->mockWebApplication(); @@ -458,6 +565,22 @@ public function testReturnParentCsrfTokenFromHeaderWhenAdapterIsNull(): void ); } + public function testReturnParentHttpMethodWhenAdapterIsNull(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + // ensure adapter is 'null' (default state) + $request->reset(); + $method = $request->getMethod(); + + self::assertNotEmpty( + $method, + 'HTTP method should not be empty when adapter is null.', + ); + } + public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies(): void { $this->mockWebApplication(); From e1f1b7fd4e4286e0db72fb20e344431618340e04 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 05:47:24 -0400 Subject: [PATCH 18/32] feat(PSR7RequestTest): add test for returning PSR-7 request instance when adapter is set, --- tests/http/PSR7RequestTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index bbcf01bf..feb003ad 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -5,6 +5,7 @@ namespace yii2\extensions\psrbridge\tests\http; use PHPUnit\Framework\Attributes\Group; +use Psr\Http\Message\ServerRequestInterface; use Yii; use yii\base\InvalidConfigException; use yii\helpers\Json; @@ -581,6 +582,23 @@ public function testReturnParentHttpMethodWhenAdapterIsNull(): void ); } + public function testReturnPsr7RequestInstanceWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + + self::assertInstanceOf( + ServerRequestInterface::class, + $request->getPsr7Request(), + "'getPsr7Request()' should return a '" . ServerRequestInterface::class . "' instance when the 'PSR-7' " . + 'adapter is set.', + ); + } + public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies(): void { $this->mockWebApplication(); From 75c58b4ded4b0600c37dab151e971fe41bd1874e Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 06:09:49 -0400 Subject: [PATCH 19/32] feat(PSR7RequestTest): add tests for `getParsedBody()` method with various input scenarios. --- src/http/Request.php | 5 +- tests/http/PSR7RequestTest.php | 116 +++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/http/Request.php b/src/http/Request.php index c875be35..3346a0e9 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -68,7 +68,10 @@ public function getMethod(): string return parent::getMethod(); } - public function getParsedBody(): mixed + /** + * @phpstan-return array|object|null + */ + public function getParsedBody(): array|object|null { return $this->getAdapter()->getParsedBody(); } diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index feb003ad..2da01076 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -582,6 +582,110 @@ public function testReturnParentHttpMethodWhenAdapterIsNull(): void ); } + public function testReturnParsedBodyArrayWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $parsedBodyData = [ + 'name' => 'John', + 'email' => 'john@example.com', + 'age' => 30, + ]; + + $psr7Request = FactoryHelper::createRequest( + 'POST', + '/api/users', + ['Content-Type' => 'application/json'], + $parsedBodyData, + ); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $result = $request->getParsedBody(); + + self::assertIsArray( + $result, + "Parsed body should return an array when 'PSR-7' request contains array data.", + ); + self::assertSame( + $parsedBodyData, + $result, + "Parsed body should match the original data from 'PSR-7' request.", + ); + self::assertArrayHasKey( + 'name', + $result, + 'Parsed body should contain the name field.', + ); + self::assertSame( + 'John', + $result['name'], + 'Name field should match the expected value.', + ); + } + + public function testReturnParsedBodyNullWhenAdapterIsSetWithNullBody(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest( + 'GET', + '/api/users', + [], + null, + ); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $result = $request->getParsedBody(); + + self::assertNull( + $result, + "Parsed body should return 'null' when 'PSR-7' request has no parsed body.", + ); + } + + public function testReturnParsedBodyObjectWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $parsedBodyObject = (object) [ + 'title' => 'Test Article', + 'content' => 'Article content', + ]; + + $psr7Request = FactoryHelper::createRequest( + 'PUT', + '/api/articles/1', + ['Content-Type' => 'application/json'], + $parsedBodyObject, + ); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $result = $request->getParsedBody(); + + self::assertIsObject( + $result, + "Parsed body should return an 'object' when 'PSR-7' request contains object data.", + ); + self::assertSame( + $parsedBodyObject, + $result, + "Parsed body object should match the original object from 'PSR-7' request.", + ); + self::assertSame( + 'Test Article', + $result->title, + 'Object title property should match the expected value.', + ); + self::assertSame( + 'Article content', + $result->content, + 'Object content property should match the expected value.', + ); + } + public function testReturnPsr7RequestInstanceWhenAdapterIsSet(): void { $this->mockWebApplication(); @@ -652,4 +756,16 @@ public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies( "'CookieCollection' should not contain invalid cookies when validation is enabled.", ); } + + public function testThrowExceptionWhenGetParsedBodyCalledWithoutAdapter(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('PSR-7 request adapter is not set.'); + + $request->getParsedBody(); + } } From 59a627d3587f8acca4e3e8470947a9704d73cd3a Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 06:26:13 -0400 Subject: [PATCH 20/32] feat(PSR7RequestTest): add tests for query parameters handling in PSR-7 requests. --- tests/http/PSR7RequestTest.php | 86 +++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index 2da01076..983fa52e 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -381,6 +381,22 @@ public function testReturnEmptyCookieCollectionWhenValidationEnabledWithInvalidC ); } + public function testReturnEmptyQueryParamsWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/products'); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $queryParams = $request->getQueryParams(); + + self::assertEmpty( + $queryParams, + "Query parameters should be empty when 'PSR-7' request has no query string.", + ); + } + public function testReturnHttpMethodFromAdapterWhenAdapterIsSet(): void { $this->mockWebApplication(); @@ -578,7 +594,23 @@ public function testReturnParentHttpMethodWhenAdapterIsNull(): void self::assertNotEmpty( $method, - 'HTTP method should not be empty when adapter is null.', + "HTTP method should not be empty when adapter is 'null'.", + ); + } + + public function testReturnParentQueryParamsWhenAdapterIsNull(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + // ensure adapter is 'null' (default state) + $request->reset(); + $queryParams = $request->getQueryParams(); + + self::assertEmpty( + $queryParams, + "Query parameters should be empty when 'PSR-7' request has no query string.", ); } @@ -615,7 +647,7 @@ public function testReturnParsedBodyArrayWhenAdapterIsSet(): void self::assertArrayHasKey( 'name', $result, - 'Parsed body should contain the name field.', + "Parsed body should contain the 'name' field.", ); self::assertSame( 'John', @@ -667,22 +699,22 @@ public function testReturnParsedBodyObjectWhenAdapterIsSet(): void self::assertIsObject( $result, - "Parsed body should return an 'object' when 'PSR-7' request contains object data.", + "Parsed body should return an 'object' when 'PSR-7' request contains 'object' data.", ); self::assertSame( $parsedBodyObject, $result, - "Parsed body object should match the original object from 'PSR-7' request.", + "Parsed body 'object' should match the original 'object' from 'PSR-7' request.", ); self::assertSame( 'Test Article', $result->title, - 'Object title property should match the expected value.', + "Object 'title' property should match the expected value.", ); self::assertSame( 'Article content', $result->content, - 'Object content property should match the expected value.', + "Object 'content' property should match the expected value.", ); } @@ -703,6 +735,48 @@ public function testReturnPsr7RequestInstanceWhenAdapterIsSet(): void ); } + public function testReturnQueryParamsWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/products?category=electronics&price=500&sort=desc'); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $queryParams = $request->getQueryParams(); + + self::assertArrayHasKey( + 'category', + $queryParams, + "Query parameters should contain the key 'category' when present in the 'PSR-7' request URI.", + ); + self::assertSame( + 'electronics', + $queryParams['category'] ?? null, + "Query parameter 'category' should have the expected value from the 'PSR-7' request URI.", + ); + self::assertArrayHasKey( + 'price', + $queryParams, + "Query parameters should contain the key 'price' when present in the 'PSR-7' request URI.", + ); + self::assertSame( + '500', + $queryParams['price'] ?? null, + "Query parameter 'price' should have the expected value from the 'PSR-7' request URI.", + ); + self::assertArrayHasKey( + 'sort', + $queryParams, + "Query parameters should contain the key 'sort' when present in the 'PSR-7' request URI.", + ); + self::assertSame( + 'desc', + $queryParams['sort'] ?? null, + "Query parameter 'sort' should have the expected value from the 'PSR-7' request URI.", + ); + } + public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies(): void { $this->mockWebApplication(); From b1e7e7ee18e275bcb7c70356d5b97d0961ef9eb7 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 06:51:54 -0400 Subject: [PATCH 21/32] feat(`Request`, `PSR7RequestTest`): add `getQueryString()` method to retrieve query string from request URI with various scenarios. --- src/http/Request.php | 17 ++++++++ tests/http/PSR7RequestTest.php | 62 +++++++++++++++++++++++------ tests/http/RequestTest.php | 63 +++++++++++++++++++++++------- tests/provider/RequestProvider.php | 33 ++++++++++++++++ 4 files changed, 149 insertions(+), 26 deletions(-) diff --git a/src/http/Request.php b/src/http/Request.php index 3346a0e9..d6534768 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -93,6 +93,23 @@ public function getQueryParams(): array return parent::getQueryParams(); } + /** + * Retrieves the query string from the current request. + * + * Returns the query string portion of the request URI, which contains parameters sent via GET method. + * + * When using PSR-7 adapter, the query string is extracted from the PSR-7 request URI. Otherwise, falls back + * to the parent implementation which typically reads from $_SERVER['QUERY_STRING']. + * + * The query string includes all parameters after the '?' character in the URL, without the leading '?'. + * + * @return string Query string without leading '?' character, or empty string if no query parameters exist. + * + * Usage example: + * ```php + * $queryString = $request->getQueryString(); // Returns 'page=1&limit=10' + * ``` + */ public function getQueryString() { if ($this->adapter !== null) { diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index 983fa52e..e947bb56 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -4,13 +4,14 @@ namespace yii2\extensions\psrbridge\tests\http; -use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; use Psr\Http\Message\ServerRequestInterface; use Yii; use yii\base\InvalidConfigException; use yii\helpers\Json; use yii\web\CookieCollection; use yii2\extensions\psrbridge\http\Request; +use yii2\extensions\psrbridge\tests\provider\RequestProvider; use yii2\extensions\psrbridge\tests\support\FactoryHelper; use yii2\extensions\psrbridge\tests\TestCase; @@ -397,6 +398,19 @@ public function testReturnEmptyQueryParamsWhenAdapterIsSet(): void ); } + public function testReturnEmptyQueryStringWhenAdapterIsSetWithNoQuery(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $result = $request->getQueryString(); + + self::assertEmpty($result, 'Query string should be empty when no query parameters are present.'); + } + public function testReturnHttpMethodFromAdapterWhenAdapterIsSet(): void { $this->mockWebApplication(); @@ -592,10 +606,7 @@ public function testReturnParentHttpMethodWhenAdapterIsNull(): void $request->reset(); $method = $request->getMethod(); - self::assertNotEmpty( - $method, - "HTTP method should not be empty when adapter is 'null'.", - ); + self::assertNotEmpty($method, "HTTP method should not be empty when adapter is 'null'."); } public function testReturnParentQueryParamsWhenAdapterIsNull(): void @@ -608,9 +619,20 @@ public function testReturnParentQueryParamsWhenAdapterIsNull(): void $request->reset(); $queryParams = $request->getQueryParams(); + self::assertEmpty($queryParams, "Query parameters should be empty when 'PSR-7' request has no query string."); + } + + public function testReturnParentQueryStringWhenAdapterIsNull(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $queryString = $request->getQueryString(); + self::assertEmpty( - $queryParams, - "Query parameters should be empty when 'PSR-7' request has no query string.", + $queryString, + "Query string should be empty when 'PSR-7' request has no query string and adapter is 'null'.", ); } @@ -671,10 +693,7 @@ public function testReturnParsedBodyNullWhenAdapterIsSetWithNullBody(): void $request->setPsr7Request($psr7Request); $result = $request->getParsedBody(); - self::assertNull( - $result, - "Parsed body should return 'null' when 'PSR-7' request has no parsed body.", - ); + self::assertNull($result, "Parsed body should return 'null' when 'PSR-7' request has no parsed body."); } public function testReturnParsedBodyObjectWhenAdapterIsSet(): void @@ -777,6 +796,27 @@ public function testReturnQueryParamsWhenAdapterIsSet(): void ); } + /** + * @phpstan-param string $expectedString + */ + #[DataProviderExternal(RequestProvider::class, 'getQueryString')] + public function testReturnQueryStringWhenAdapterIsSet(string $queryString, string $expectedString): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', "/test?{$queryString}"); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $result = $request->getQueryString(); + + self::assertSame( + $expectedString, + $result, + "Query string should match the expected value for: '{$queryString}'.", + ); + } + public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies(): void { $this->mockWebApplication(); diff --git a/tests/http/RequestTest.php b/tests/http/RequestTest.php index f7cedf4e..76409a2f 100644 --- a/tests/http/RequestTest.php +++ b/tests/http/RequestTest.php @@ -40,21 +40,9 @@ public function testAlreadyResolvedIp( ], ); - self::assertSame( - $expectedRemoteAddress, - $request->remoteIP, - 'Remote IP fail!.', - ); - self::assertSame( - $expectedUserIp, - $request->userIP, - 'User IP fail!.', - ); - self::assertSame( - $expectedIsSecureConnection, - $request->isSecureConnection, - 'Secure connection fail!.', - ); + self::assertSame($expectedRemoteAddress, $request->remoteIP, 'Remote IP fail!.'); + self::assertSame($expectedUserIp, $request->userIP, 'User IP fail!.'); + self::assertSame($expectedIsSecureConnection, $request->isSecureConnection, 'Secure connection fail!.'); } public function testCsrfHeaderValidation(): void @@ -773,6 +761,51 @@ public function testGetOrigin(): void ); } + public function testGetQueryStringWhenEmpty(): void + { + $_SERVER['QUERY_STRING'] = ''; + + $request = new Request(); + + self::assertEmpty( + $request->getQueryString(), + 'Query string should be empty when \'$_SERVER[\'QUERY_STRING\']\' is empty.', + ); + + unset($_SERVER['QUERY_STRING']); + } + + public function testGetQueryStringWhenNotSet(): void + { + unset($_SERVER['QUERY_STRING']); + + $request = new Request(); + + self::assertEmpty( + $request->getQueryString(), + 'Query string should be empty when \'$_SERVER[\'QUERY_STRING\']\' is not set.', + ); + } + + /** + * @phpstan-param string $expectedString + */ + #[DataProviderExternal(RequestProvider::class, 'getQueryString')] + public function testGetQueryStringWithVariousParams(string $queryString, string $expectedString): void + { + $_SERVER['QUERY_STRING'] = $queryString; + + $request = new Request(); + + self::assertSame( + $expectedString, + $request->getQueryString(), + "Query string should match the expected value for: '{$queryString}'.", + ); + + unset($_SERVER['QUERY_STRING']); + } + public function testGetScriptFileWithEmptyServer(): void { $request = new Request(); diff --git a/tests/provider/RequestProvider.php b/tests/provider/RequestProvider.php index 9386f323..650026fd 100644 --- a/tests/provider/RequestProvider.php +++ b/tests/provider/RequestProvider.php @@ -250,6 +250,39 @@ public static function getMethod(): array ]; } + /** + * @phpstan-return array + */ + public static function getQueryString(): array + { + return [ + 'complexQuery' => [ + 'filters%5Btype%5D=article&filters%5Bstatus%5D=published&tags%5B%5D=php&tags%5B%5D=web', + 'filters%5Btype%5D=article&filters%5Bstatus%5D=published&tags%5B%5D=php&tags%5B%5D=web', + ], + 'emptyQuery' => [ + '', + '', + ], + 'encodedParameters' => [ + 'search=hello%20world&category=tech%26science', + 'search=hello%20world&category=tech%26science', + ], + 'multipleParameters' => [ + 'page=1&limit=10&sort=name', + 'page=1&limit=10&sort=name', + ], + 'parameterWithoutValue' => [ + 'debug&verbose=1', + 'debug&verbose=1', + ], + 'singleParameter' => [ + 'page=1', + 'page=1', + ], + ]; + } + /** * @phpstan-return array, string}> */ From aa61f027ae1c4dedb9dd8b7c0f0c7849c3d78446 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 07:02:15 -0400 Subject: [PATCH 22/32] feat(PSR7RequestTest): add tests for `getRawBody()` method with various scenarios. --- src/adapter/ServerRequestAdapter.php | 1 + tests/http/PSR7RequestTest.php | 55 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index 991f45c4..64714942 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -115,6 +115,7 @@ public function getQueryString(): string public function getRawBody(): string { $body = $this->psrRequest->getBody(); + $body->rewind(); return $body->getContents(); diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index e947bb56..4cde11ed 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -636,6 +636,22 @@ public function testReturnParentQueryStringWhenAdapterIsNull(): void ); } + public function testReturnParentRawBodyWhenAdapterIsNull(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + // ensure adapter is 'null' (default state) + $request->reset(); + $result = $request->getRawBody(); + + self::assertEmpty( + $result, + "Raw body should return empty string when 'PSR-7' request has no body content and adapter is 'null'.", + ); + } + public function testReturnParsedBodyArrayWhenAdapterIsSet(): void { $this->mockWebApplication(); @@ -817,6 +833,45 @@ public function testReturnQueryStringWhenAdapterIsSet(string $queryString, strin ); } + public function testReturnRawBodyFromAdapterWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $bodyContent = '{"name":"John","email":"john@example.com","message":"Hello World"}'; + + $stream = FactoryHelper::createStream('php://temp', 'wb+'); + + $stream->write($bodyContent); + + $psr7Request = FactoryHelper::createRequest('POST', '/api/contact')->withBody($stream); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $result = $request->getRawBody(); + + self::assertSame( + $bodyContent, + $result, + "Raw body should return the exact content from the 'PSR-7' request body when adapter is set.", + ); + } + + public function testReturnRawBodyWhenAdapterIsSetWithEmptyBody(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $result = $request->getRawBody(); + + self::assertEmpty( + $result, + "Raw body should return empty string when 'PSR-7' request has no body content.", + ); + } + public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies(): void { $this->mockWebApplication(); From b6452213fc5a301a61843a4c8823cfc94f6eff35 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 07:16:59 -0400 Subject: [PATCH 23/32] feat(PSR7RequestTest): add tests for `getScriptUrl()` method in various scenarios. --- src/adapter/ServerRequestAdapter.php | 4 +- src/http/Request.php | 2 +- tests/http/PSR7RequestTest.php | 79 ++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index 64714942..4d7db6b9 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -125,12 +125,12 @@ public function getScriptUrl(bool $workerMode): string { $serverParams = $this->psrRequest->getServerParams(); - // for traditional PSR-7 apps where SCRIPT_NAME is available + // for traditional 'PSR-7' apps where 'SCRIPT_NAME' is available if ($workerMode === false && isset($serverParams['SCRIPT_NAME']) && is_string($serverParams['SCRIPT_NAME'])) { return $serverParams['SCRIPT_NAME']; } - // for PSR-7 workers (RoadRunner, Franken, etc.) where no script file exists + // for 'PSR-7' workers (RoadRunner, FrankenPHP, etc.) where no script file exists // return empty to prevent URL duplication as routing is handled internally return ''; } diff --git a/src/http/Request.php b/src/http/Request.php index d6534768..4c20e614 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -11,8 +11,8 @@ final class Request extends \yii\web\Request { + public bool $workerMode = true; private ServerRequestAdapter|null $adapter = null; - private bool $workerMode = true; /** * @phpstan-return array|object diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index 4cde11ed..a8975b6f 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -18,6 +18,27 @@ #[Group('http')] final class PSR7RequestTest extends TestCase { + public function testCallParentGetScriptUrlWhenAdapterIsNull(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + // ensure adapter is 'null' (default state) + $request->reset(); + + $_SERVER['SCRIPT_NAME'] = '/test.php'; + $_SERVER['SCRIPT_FILENAME'] = '/path/to/test.php'; + + $scriptUrl = $request->getScriptUrl(); + + // kust verify the method executes without throwing exception when adapter is 'null' + self::assertSame( + '/test.php', + $scriptUrl, + "'getScriptUrl()' should return 'SCRIPT_NAME' when adapter is 'null'.", + ); + } public function testResetCookieCollectionAfterReset(): void { $this->mockWebApplication(); @@ -411,6 +432,39 @@ public function testReturnEmptyQueryStringWhenAdapterIsSetWithNoQuery(): void self::assertEmpty($result, 'Query string should be empty when no query parameters are present.'); } + public function testReturnEmptyScriptUrlWhenAdapterIsSetInTraditionalModeWithoutScriptName(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test', [], null, []); + $request = new Request(['workerMode' => false]); + + $request->setPsr7Request($psr7Request); + $scriptUrl = $request->getScriptUrl(); + + self::assertEmpty( + $scriptUrl, + "Script URL should be empty when adapter is set in traditional mode without 'SCRIPT_NAME'.", + ); + } + + public function testReturnEmptyScriptUrlWhenAdapterIsSetInWorkerMode(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $scriptUrl = $request->getScriptUrl(); + + self::assertSame( + '', + $scriptUrl, + 'Script URL should be empty when adapter is set in worker mode (default).', + ); + } + public function testReturnHttpMethodFromAdapterWhenAdapterIsSet(): void { $this->mockWebApplication(); @@ -872,6 +926,31 @@ public function testReturnRawBodyWhenAdapterIsSetWithEmptyBody(): void ); } + public function testReturnScriptNameWhenAdapterIsSetInTraditionalMode(): void + { + $this->mockWebApplication(); + + $expectedScriptName = '/app/public/index.php'; + + $psr7Request = FactoryHelper::createRequest( + 'GET', + '/test', + [], + null, + ['SCRIPT_NAME' => $expectedScriptName], + ); + $request = new Request(['workerMode' => false]); + + $request->setPsr7Request($psr7Request); + $scriptUrl = $request->getScriptUrl(); + + self::assertSame( + $expectedScriptName, + $scriptUrl, + "Script URL should return 'SCRIPT_NAME' when adapter is set in traditional mode.", + ); + } + public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies(): void { $this->mockWebApplication(); From b10e30f08886e8c8f4b3ece4e9302055c9977352 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sun, 20 Jul 2025 11:17:24 +0000 Subject: [PATCH 24/32] Apply fixes from StyleCI --- tests/http/PSR7RequestTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index a8975b6f..2cec3b99 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -39,6 +39,7 @@ public function testCallParentGetScriptUrlWhenAdapterIsNull(): void "'getScriptUrl()' should return 'SCRIPT_NAME' when adapter is 'null'.", ); } + public function testResetCookieCollectionAfterReset(): void { $this->mockWebApplication(); From e53934ba9e51c5afb9de103640301df380ca44bf Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 10:25:14 -0400 Subject: [PATCH 25/32] feat(Request, PSR7RequestTest): implement file upload handling and add tests for uploaded files. --- src/adapter/ServerRequestAdapter.php | 127 +-------------------------- src/http/Request.php | 44 +++++++++- test1.txt | 1 + tests/http/PSR7RequestTest.php | 109 +++++++++++++++++++++++ tests/provider/RequestProvider.php | 2 +- tests/support/FactoryHelper.php | 29 +++++- tests/support/stub/files/test1.txt | 1 + tests/support/stub/files/test2.php | 3 + 8 files changed, 186 insertions(+), 130 deletions(-) create mode 100644 test1.txt create mode 100644 tests/support/stub/files/test1.txt create mode 100644 tests/support/stub/files/test2.php diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index 4d7db6b9..46ede86c 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -4,7 +4,7 @@ namespace yii2\extensions\psrbridge\adapter; -use Psr\Http\Message\{ServerRequestInterface, UploadedFileInterface}; +use Psr\Http\Message\{ServerRequestInterface}; use Yii; use yii\base\InvalidConfigException; use yii\helpers\Json; @@ -144,22 +144,11 @@ public function getServerParams(): array } /** - * @phpstan-return array< - * string, - * array{ - * name: string|array, - * type: string|array, - * tmp_name: string|array, - * error: int|array, - * size: int|array, - * } - * > + * @phpstan-return array */ public function getUploadedFiles(): array { - /** @phpstan-var array> $uploadedFiles */ - $uploadedFiles = $this->psrRequest->getUploadedFiles(); - return $this->normalizeUploadedFiles($uploadedFiles); + return $this->psrRequest->getUploadedFiles(); } public function getUrl(): string @@ -174,38 +163,6 @@ public function getUrl(): string return $url; } - /** - * @phpstan-return array{name: string, type: string, tmp_name: string, error: int, size: int} - */ - private function convertSingleFile(UploadedFileInterface $file): array - { - $stream = $file->getStream(); - $streamMeta = $stream->getMetadata(); - $tempName = ''; - - if (is_array($streamMeta) && isset($streamMeta['uri']) && is_string($streamMeta['uri'])) { - $tempName = $streamMeta['uri']; - - // For in-memory streams, create a temporary file - if (str_starts_with($tempName, 'php://')) { - $tempFile = tempnam(sys_get_temp_dir(), 'upload'); - if ($tempFile !== false) { - $stream->rewind(); - file_put_contents($tempFile, $stream->getContents()); - $tempName = $tempFile; - } - } - } - - return [ - 'name' => $file->getClientFilename() ?? '', - 'type' => $file->getClientMediaType() ?? '', - 'tmp_name' => $tempName, - 'error' => $file->getError(), - 'size' => $file->getSize() ?? 0, - ]; - } - /** * @phpstan-return array */ @@ -267,82 +224,4 @@ private function getValidatedCookies(string $validationKey): array return $cookies; } - - /** - * @phpstan-param array $fileArray - * - * @phpstan-return array{ - * name: array, - * type: array, - * tmp_name: array, - * error: array, - * size: array, - * } - */ - private function normalizeFileArray(array $fileArray): array - { - $names = []; - $types = []; - $tmpNames = []; - $errors = []; - $sizes = []; - - foreach ($fileArray as $key => $file) { - if ($file instanceof UploadedFileInterface) { - $converted = $this->convertSingleFile($file); - $names[$key] = $converted['name']; - $types[$key] = $converted['type']; - $tmpNames[$key] = $converted['tmp_name']; - $errors[$key] = $converted['error']; - $sizes[$key] = $converted['size']; - } elseif (is_array($file)) { - // Nested array - recursively normalize - $nestedNormalized = $this->normalizeFileArray($file); - $names[$key] = $nestedNormalized['name']; - $types[$key] = $nestedNormalized['type']; - $tmpNames[$key] = $nestedNormalized['tmp_name']; - $errors[$key] = $nestedNormalized['error']; - $sizes[$key] = $nestedNormalized['size']; - } - } - - return [ - 'name' => $names, - 'type' => $types, - 'tmp_name' => $tmpNames, - 'error' => $errors, - 'size' => $sizes, - ]; - } - - /** - * @phpstan-param array> $uploadedFiles - * - * @phpstan-return array< - * string, - * array{ - * name: string|array, - * type: string|array, - * tmp_name: string|array, - * error: int|array, - * size: int|array, - * } - * > - */ - private function normalizeUploadedFiles(array $uploadedFiles): array - { - $normalized = []; - - foreach ($uploadedFiles as $fieldName => $fileData) { - if ($fileData instanceof UploadedFileInterface) { - // Single file - $normalized[$fieldName] = $this->convertSingleFile($fileData); - } elseif (is_array($fileData)) { - // Multiple files or nested structure - $normalized[$fieldName] = $this->normalizeFileArray($fileData); - } - } - - return $normalized; - } } diff --git a/src/http/Request.php b/src/http/Request.php index 4c20e614..c9ae7fbc 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -6,7 +6,7 @@ use Psr\Http\Message\{ServerRequestInterface, UploadedFileInterface}; use yii\base\InvalidConfigException; -use yii\web\{CookieCollection, HeaderCollection}; +use yii\web\{CookieCollection, HeaderCollection, UploadedFile}; use yii2\extensions\psrbridge\adapter\ServerRequestAdapter; final class Request extends \yii\web\Request @@ -138,12 +138,15 @@ public function getScriptUrl(): string } /** - * @return array|UploadedFileInterface> + * @phpstan-return array */ public function getUploadedFiles(): array { - // @phpstan-ignore return.type - return $this->getPsr7Request()->getUploadedFiles(); + if ($this->adapter !== null) { + return $this->convertPsr7ToUploadedFiles($this->adapter->getUploadedFiles()); + } + + return []; } public function getUrl(): string @@ -161,6 +164,39 @@ public function setPsr7Request(ServerRequestInterface $request): void $this->adapter = new ServerRequestAdapter($request); } + /** + * @phpstan-param array $uploadedFiles + * + * @phpstan-return array, mixed> + */ + private function convertPsr7ToUploadedFiles(array $uploadedFiles): array + { + $converted = []; + + foreach ($uploadedFiles as $key => $file) { + if ($file instanceof UploadedFileInterface) { + $converted[$key] = $this->createUploadedFile($file); + } elseif (is_array($file)) { + $converted[$key] = $this->convertPsr7ToUploadedFiles($file); + } + } + + return $converted; + } + + private function createUploadedFile(UploadedFileInterface $psrFile): UploadedFile + { + return new UploadedFile( + [ + 'error' => $psrFile->getError(), + 'name' => $psrFile->getClientFilename() ?? '', + 'size' => $psrFile->getSize() ?? 0, + 'tempName' => $psrFile->getStream()->getMetadata('uri') ?? '', + 'type' => $psrFile->getClientMediaType() ?? '', + ], + ); + } + private function getAdapter(): ServerRequestAdapter { if ($this->adapter === null) { diff --git a/test1.txt b/test1.txt new file mode 100644 index 00000000..10ddd6d2 --- /dev/null +++ b/test1.txt @@ -0,0 +1 @@ +Hello! diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index a8975b6f..52686f7e 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -10,11 +10,15 @@ use yii\base\InvalidConfigException; use yii\helpers\Json; use yii\web\CookieCollection; +use yii\web\UploadedFile; use yii2\extensions\psrbridge\http\Request; use yii2\extensions\psrbridge\tests\provider\RequestProvider; use yii2\extensions\psrbridge\tests\support\FactoryHelper; use yii2\extensions\psrbridge\tests\TestCase; +use function dirname; +use function filesize; + #[Group('http')] final class PSR7RequestTest extends TestCase { @@ -951,6 +955,111 @@ public function testReturnScriptNameWhenAdapterIsSetInTraditionalMode(): void ); } + public function testReturnUploadedFilesWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $ds = DIRECTORY_SEPARATOR; + + $file1 = dirname(__DIR__) . '/support/stub/files/test1.txt'; + $file2 = dirname(__DIR__) . '/support/stub/files/test2.php'; + $size1 = filesize($file1); + $size2 = filesize($file2); + + self::assertNotFalse( + $size1, + "File size for 'test1.txt' should not be 'false'.", + ); + self::assertNotFalse( + $size2, + "File size for 'test2.php' should not be 'false'.", + ); + + $uploadedFile1 = FactoryHelper::createUploadedFile('test1.txt', 'text/plain', $file1, size: $size1); + $uploadedFile2 = FactoryHelper::createUploadedFile('test2.php', 'application/x-php', $file2, size: $size2); + $psr7Request = FactoryHelper::createRequest('POST', '/upload'); + + $psr7Request = $psr7Request->withUploadedFiles( + [ + 'file1' => $uploadedFile1, + 'file2' => $uploadedFile2, + ], + ); + + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $uploadedFiles = $request->getUploadedFiles(); + + $expectedNames = [ + 'file1', + 'file2', + ]; + $expectedUpdloadedFiles = [ + 'file1' => [ + 'name' => 'test1.txt', + 'type' => 'text/plain', + 'tempName' => $file1, + 'error' => UPLOAD_ERR_OK, + 'size' => $size1, + ], + 'file2' => [ + 'name' => 'test2.php', + 'type' => 'application/x-php', + 'tempName' => $file2, + 'error' => UPLOAD_ERR_OK, + 'size' => $size2, + ], + ]; + $runtimePath = dirname(__DIR__, 2) . '/runtime'; + + foreach ($uploadedFiles as $name => $uploadedFile) { + self::assertContains( + $name, + $expectedNames, + "Uploaded file name '{$name}' should be in the expected names list.", + ); + self::assertInstanceOf( + UploadedFile::class, + $uploadedFile, + "Uploaded file '{$name}' should be an instance of '" . UploadedFile::class . "'.", + ); + self::assertSame( + $expectedUpdloadedFiles[$name]['name'] ?? null, + $uploadedFile->name, + "Uploaded file '{$name}' should have the expected client filename.", + ); + self::assertSame( + $expectedUpdloadedFiles[$name]['type'] ?? null, + $uploadedFile->type, + "Uploaded file '{$name}' should have the expected client media type.", + ); + self::assertSame( + $expectedUpdloadedFiles[$name]['tempName'] ?? null, + $uploadedFile->tempName, + "Uploaded file '{$name}' should have the expected temporary name.", + ); + self::assertSame( + $expectedUpdloadedFiles[$name]['error'] ?? null, + $uploadedFile->error, + "Uploaded file '{$name}' should have the expected error code.", + ); + self::assertSame( + $expectedUpdloadedFiles[$name]['size'] ?? null, + $uploadedFile->size, + "Uploaded file '{$name }' should have the expected size.", + ); + self::assertTrue( + $uploadedFile->saveAs("{$runtimePath}{$ds}{$uploadedFile->name}", false), + "Uploaded file '{$uploadedFile->name}' should be saved to the runtime directory successfully.", + ); + self::assertFileExists( + "{$runtimePath}{$ds}{$uploadedFile->name}", + "Uploaded file '{$uploadedFile->name}' should exist in the runtime directory after saving.", + ); + } + } + public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies(): void { $this->mockWebApplication(); diff --git a/tests/provider/RequestProvider.php b/tests/provider/RequestProvider.php index 650026fd..878f32a9 100644 --- a/tests/provider/RequestProvider.php +++ b/tests/provider/RequestProvider.php @@ -239,7 +239,7 @@ public static function getMethod(): array [ [ 'REQUEST_METHOD' => 'DEFAULT', - 'HTTP_X-HTTP-METHOD-OVERRIDE' => 'OVERRIDE', + 'HTTP_X_HTTP_METHOD_OVERRIDE' => 'OVERRIDE', ], 'OVERRIDE', ], diff --git a/tests/support/FactoryHelper.php b/tests/support/FactoryHelper.php index 0be8782b..9c44f19a 100644 --- a/tests/support/FactoryHelper.php +++ b/tests/support/FactoryHelper.php @@ -4,13 +4,14 @@ namespace yii2\extensions\psrbridge\tests\support; -use HttpSoft\Message\{Response, ResponseFactory, ServerRequest, Stream, StreamFactory, Uri}; +use HttpSoft\Message\{Response, ResponseFactory, ServerRequest, Stream, StreamFactory, UploadedFile, Uri}; use Psr\Http\Message\{ ResponseFactoryInterface, ResponseInterface, ServerRequestInterface, StreamFactoryInterface, StreamInterface, + UploadedFileInterface, UriInterface, }; @@ -161,6 +162,32 @@ public static function createStreamFactory(): StreamFactoryInterface return new StreamFactory(); } + /** + * Creates a PSR-7 {@see UploadedFile} instance. + * + * @param string $name Client filename. + * @param string $type Client media type. + * @param string $tmpName Temporary file name. + * @param int $error Upload error code. + * @param int $size File size. + * + * @return UploadedFileInterface PSR-7 uploaded file instance. + * + * Usage example: + * ```php + * FactoryHelper::createUploadedFile($name, $type, $tmpName, $error, $size); + * ``` + */ + public static function createUploadedFile( + string $name = '', + string $type = '', + string $tmpName = '', + int $error = 0, + int $size = 0, + ): UploadedFileInterface { + return new UploadedFile($tmpName, $size, $error, $name, $type); + } + /** * Creates a PSR-7 {@see UriInterface} instance. * diff --git a/tests/support/stub/files/test1.txt b/tests/support/stub/files/test1.txt new file mode 100644 index 00000000..10ddd6d2 --- /dev/null +++ b/tests/support/stub/files/test1.txt @@ -0,0 +1 @@ +Hello! diff --git a/tests/support/stub/files/test2.php b/tests/support/stub/files/test2.php new file mode 100644 index 00000000..174d7fd7 --- /dev/null +++ b/tests/support/stub/files/test2.php @@ -0,0 +1,3 @@ + Date: Sun, 20 Jul 2025 11:15:15 -0400 Subject: [PATCH 26/32] feat(Request, PSR7RequestTest, RequestTest, RequestProvider): enhance request handling and add comprehensive tests for URL retrieval and uploaded files. --- src/http/Request.php | 29 ++++++++++------- tests/http/PSR7RequestTest.php | 52 +++++++++++++++++++++++------- tests/http/RequestTest.php | 52 ++++++++++++++++++++++++++++++ tests/provider/RequestProvider.php | 21 ++++++++++++ 4 files changed, 130 insertions(+), 24 deletions(-) diff --git a/src/http/Request.php b/src/http/Request.php index c9ae7fbc..9f905a7f 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -9,6 +9,8 @@ use yii\web\{CookieCollection, HeaderCollection, UploadedFile}; use yii2\extensions\psrbridge\adapter\ServerRequestAdapter; +use function is_array; + final class Request extends \yii\web\Request { public bool $workerMode = true; @@ -73,12 +75,20 @@ public function getMethod(): string */ public function getParsedBody(): array|object|null { - return $this->getAdapter()->getParsedBody(); + if ($this->adapter !== null) { + return $this->adapter->getParsedBody(); + } + + return parent::getBodyParams(); } public function getPsr7Request(): ServerRequestInterface { - return $this->getAdapter()->psrRequest; + if ($this->adapter === null) { + throw new InvalidConfigException('PSR-7 request adapter is not set.'); + } + + return $this->adapter->psrRequest; } /** @@ -151,7 +161,11 @@ public function getUploadedFiles(): array public function getUrl(): string { - return $this->getAdapter()->getUrl(); + if ($this->adapter !== null) { + return $this->adapter->getUrl(); + } + + return parent::getUrl(); } public function reset(): void @@ -196,13 +210,4 @@ private function createUploadedFile(UploadedFileInterface $psrFile): UploadedFil ], ); } - - private function getAdapter(): ServerRequestAdapter - { - if ($this->adapter === null) { - throw new InvalidConfigException('PSR-7 request adapter is not set.'); - } - - return $this->adapter; - } } diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index 60d86f1e..97e4b2fa 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -711,6 +711,28 @@ public function testReturnParentRawBodyWhenAdapterIsNull(): void ); } + public function testReturnParentUrlWhenAdapterIsNull(): void + { + $_SERVER['REQUEST_URI'] = '/legacy/path?param=value'; + + $this->mockWebApplication(); + + $request = new Request(); + + // ensure adapter is 'null' (default state) + $request->reset(); + + $url = $request->getUrl(); + + self::assertSame( + '/legacy/path?param=value', + $url, + "URL should return parent implementation result when adapter is 'null'.", + ); + + unset($_SERVER['REQUEST_URI']); + } + public function testReturnParsedBodyArrayWhenAdapterIsSet(): void { $this->mockWebApplication(); @@ -1061,6 +1083,24 @@ public function testReturnUploadedFilesWhenAdapterIsSet(): void } } + #[DataProviderExternal(RequestProvider::class, 'getUrl')] + public function testReturnUrlFromAdapterWhenAdapterIsSet(string $url, string $expectedUrl): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', $url); + $request = new Request(); + + $request->setPsr7Request($psr7Request); + $url = $request->getUrl(); + + self::assertSame( + $expectedUrl, + $url, + "URL should match the expected value for: {$url}.", + ); + } + public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies(): void { $this->mockWebApplication(); @@ -1114,16 +1154,4 @@ public function testReturnValidatedCookiesWhenValidationEnabledWithValidCookies( "'CookieCollection' should not contain invalid cookies when validation is enabled.", ); } - - public function testThrowExceptionWhenGetParsedBodyCalledWithoutAdapter(): void - { - $this->mockWebApplication(); - - $request = new Request(); - - $this->expectException(InvalidConfigException::class); - $this->expectExceptionMessage('PSR-7 request adapter is not set.'); - - $request->getParsedBody(); - } } diff --git a/tests/http/RequestTest.php b/tests/http/RequestTest.php index 76409a2f..add89b75 100644 --- a/tests/http/RequestTest.php +++ b/tests/http/RequestTest.php @@ -870,6 +870,46 @@ public function testGetServerPort(): void ); } + public function testGetUploadedFiles(): void + { + $request = new Request(); + + self::assertEmpty( + $request->getUploadedFiles(), + "'getUploadedFiles()' should return an empty array when not set up for PSR7 request handling.", + ); + } + + public function testGetUrlWhenRequestUriIsSet(): void + { + $_SERVER['REQUEST_URI'] = '/search?q=hello+world&category=books&price[min]=10&price[max]=50'; + + $request = new Request(); + + self::assertSame( + '/search?q=hello+world&category=books&price[min]=10&price[max]=50', + $request->getUrl(), + 'URL should match the value of \'REQUEST_URI\' when it is set.', + ); + + unset($_SERVER['REQUEST_URI']); + } + + public function testGetUrlWithRootPath(): void + { + $_SERVER['REQUEST_URI'] = '/'; + + $request = new Request(); + + self::assertSame( + '/', + $request->getUrl(), + "URL should return 'root' path when 'REQUEST_URI' is set to 'root'.", + ); + + unset($_SERVER['REQUEST_URI']); + } + /** * @phpstan-param array}> $server * @phpstan-param string $expected @@ -1453,6 +1493,18 @@ public function testSetHostInfo(): void ); } + public function testThrowExceptionWhenRequestUriIsMissing(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Unable to determine the request URI.'); + + $request->getUrl(); + } + /** * @phpstan-param array|null $ipHeaders * @phpstan-param array $trustedHosts diff --git a/tests/provider/RequestProvider.php b/tests/provider/RequestProvider.php index 878f32a9..7bb6003c 100644 --- a/tests/provider/RequestProvider.php +++ b/tests/provider/RequestProvider.php @@ -283,6 +283,27 @@ public static function getQueryString(): array ]; } + /** + * @phpstan-return array + */ + public static function getUrl(): array + { + return [ + 'complexQueryString' => [ + '/search?q=hello+world&category=books&price[min]=10&price[max]=50', + '/search?q=hello+world&category=books&price%5Bmin%5D=10&price%5Bmax%5D=50', + ], + 'rootPath' => [ + '/', + '/', + ], + 'withoutQueryString' => [ + '/search?q=hello%20world&path=%2Fsome%2Fpath', + '/search?q=hello%20world&path=%2Fsome%2Fpath', + ], + ]; + } + /** * @phpstan-return array, string}> */ From 7ff30e2fba4c69faaba83da478b89795678cbadd Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 11:51:58 -0400 Subject: [PATCH 27/32] feat(PSR7RequestTest, RequestTest): enhance file upload tests and improve CSRF token validation handling. --- tests/http/PSR7RequestTest.php | 115 +++++++++++++++++++++++++++++++-- tests/http/RequestTest.php | 4 ++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index 97e4b2fa..7c36ab23 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -978,11 +978,117 @@ public function testReturnScriptNameWhenAdapterIsSetInTraditionalMode(): void ); } - public function testReturnUploadedFilesWhenAdapterIsSet(): void + public function testReturnUploadedFilesRecursivelyConvertsNestedArrays(): void { $this->mockWebApplication(); - $ds = DIRECTORY_SEPARATOR; + $file1 = dirname(__DIR__) . '/support/stub/files/test1.txt'; + $file2 = dirname(__DIR__) . '/support/stub/files/test2.php'; + $size1 = filesize($file1); + $size2 = filesize($file2); + + self::assertNotFalse( + $size1, + "File size for 'test1.txt' should not be 'false'.", + ); + self::assertNotFalse( + $size2, + "File size for 'test2.php' should not be 'false'.", + ); + + $uploadedFile1 = FactoryHelper::createUploadedFile('test1.txt', 'text/plain', $file1, size: $size1); + $uploadedFile2 = FactoryHelper::createUploadedFile('test2.php', 'application/x-php', $file2, size: $size2); + + $deepNestedFiles = [ + 'docs' => [ + 'sub' => [ + 'file1' => $uploadedFile1, + 'file2' => $uploadedFile2, + ], + ], + ]; + + $psr7Request = FactoryHelper::createRequest('POST', '/upload')->withUploadedFiles($deepNestedFiles); + + $request = new Request(); + $request->setPsr7Request($psr7Request); + + $deepNestedUploadedFiles = $request->getUploadedFiles(); + + $expectedUpdloadedFiles = [ + 'file1' => [ + 'name' => 'test1.txt', + 'type' => 'text/plain', + 'tempName' => $file1, + 'error' => UPLOAD_ERR_OK, + 'size' => $size1, + ], + 'file2' => [ + 'name' => 'test2.php', + 'type' => 'application/x-php', + 'tempName' => $file2, + 'error' => UPLOAD_ERR_OK, + 'size' => $size2, + ], + ]; + + $runtimePath = dirname(__DIR__, 2) . '/runtime'; + + foreach ($deepNestedUploadedFiles as $nestedUploadFiles) { + if (is_array($nestedUploadFiles)) { + foreach ($nestedUploadFiles as $uploadedFiles) { + if (is_array($uploadedFiles)) { + foreach ($uploadedFiles as $name => $uploadedFile) { + self::assertInstanceOf( + UploadedFile::class, + $uploadedFile, + "Uploaded file '{$name}' should be an instance of '" . UploadedFile::class . "'.", + ); + self::assertSame( + $expectedUpdloadedFiles[$name]['name'] ?? null, + $uploadedFile->name, + "Uploaded file '{$name}' should have the expected client filename.", + ); + self::assertSame( + $expectedUpdloadedFiles[$name]['type'] ?? null, + $uploadedFile->type, + "Uploaded file '{$name}' should have the expected client media type.", + ); + self::assertSame( + $expectedUpdloadedFiles[$name]['tempName'] ?? null, + $uploadedFile->tempName, + "Uploaded file '{$name}' should have the expected temporary name.", + ); + self::assertSame( + $expectedUpdloadedFiles[$name]['error'] ?? null, + $uploadedFile->error, + "Uploaded file '{$name}' should have the expected error code.", + ); + self::assertSame( + $expectedUpdloadedFiles[$name]['size'] ?? null, + $uploadedFile->size, + "Uploaded file '{$name}' should have the expected size.", + ); + self::assertTrue( + $uploadedFile->saveAs("{$runtimePath}/{$uploadedFile->name}", false), + "Uploaded file '{$uploadedFile->name}' should be saved to the runtime directory " . + 'successfully.', + ); + self::assertFileExists( + "{$runtimePath}/{$uploadedFile->name}", + "Uploaded file '{$uploadedFile->name}' should exist in the runtime directory after " . + 'saving.', + ); + } + } + } + } + } + } + + public function testReturnUploadedFilesWhenAdapterIsSet(): void + { + $this->mockWebApplication(); $file1 = dirname(__DIR__) . '/support/stub/files/test1.txt'; $file2 = dirname(__DIR__) . '/support/stub/files/test2.php'; @@ -1034,6 +1140,7 @@ public function testReturnUploadedFilesWhenAdapterIsSet(): void 'size' => $size2, ], ]; + $runtimePath = dirname(__DIR__, 2) . '/runtime'; foreach ($uploadedFiles as $name => $uploadedFile) { @@ -1073,11 +1180,11 @@ public function testReturnUploadedFilesWhenAdapterIsSet(): void "Uploaded file '{$name }' should have the expected size.", ); self::assertTrue( - $uploadedFile->saveAs("{$runtimePath}{$ds}{$uploadedFile->name}", false), + $uploadedFile->saveAs("{$runtimePath}/{$uploadedFile->name}", false), "Uploaded file '{$uploadedFile->name}' should be saved to the runtime directory successfully.", ); self::assertFileExists( - "{$runtimePath}{$ds}{$uploadedFile->name}", + "{$runtimePath}/{$uploadedFile->name}", "Uploaded file '{$uploadedFile->name}' should exist in the runtime directory after saving.", ); } diff --git a/tests/http/RequestTest.php b/tests/http/RequestTest.php index add89b75..8fbcf800 100644 --- a/tests/http/RequestTest.php +++ b/tests/http/RequestTest.php @@ -1091,6 +1091,8 @@ public function testHttpAuthCredentialsFromServerSuperglobal(): void public function testIssue15317(): void { + $originalCookie = $_COOKIE; + $this->mockWebApplication(); $_COOKIE[(new Request())->csrfParam] = ''; @@ -1110,6 +1112,8 @@ public function testIssue15317(): void $request->getCsrfToken(), "'getCsrfToken()' should return a non-empty value after an empty 'CSRF' token is validated.", ); + + $_COOKIE = $originalCookie; } public function testNoCsrfTokenCsrfHeaderValidation(): void From f299e78ab201c524a60cd5961ff251a47d6ba752 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 12:31:19 -0400 Subject: [PATCH 28/32] feat(PSR7RequestTest, RequestTest, ServerRequestAdapter): remove unused methods and add exception handling for missing PSR-7 adapter. --- src/adapter/ServerRequestAdapter.php | 16 -------- tests/http/PSR7RequestTest.php | 58 +++++++++++++++++----------- tests/http/RequestTest.php | 10 +++++ 3 files changed, 46 insertions(+), 38 deletions(-) diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index 46ede86c..5392605c 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -33,14 +33,6 @@ public function getBodyParams(string $methodParam): array|object return $parsedBody ?? []; } - /** - * @phpstan-return array - */ - public function getCookieParams(): array - { - return $this->psrRequest->getCookieParams(); - } - /** * @phpstan-return array */ @@ -135,14 +127,6 @@ public function getScriptUrl(bool $workerMode): string return ''; } - /** - * @phpstan-return array - */ - public function getServerParams(): array - { - return $this->psrRequest->getServerParams(); - } - /** * @phpstan-return array */ diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index 7c36ab23..bc95b189 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -22,28 +22,6 @@ #[Group('http')] final class PSR7RequestTest extends TestCase { - public function testCallParentGetScriptUrlWhenAdapterIsNull(): void - { - $this->mockWebApplication(); - - $request = new Request(); - - // ensure adapter is 'null' (default state) - $request->reset(); - - $_SERVER['SCRIPT_NAME'] = '/test.php'; - $_SERVER['SCRIPT_FILENAME'] = '/path/to/test.php'; - - $scriptUrl = $request->getScriptUrl(); - - // kust verify the method executes without throwing exception when adapter is 'null' - self::assertSame( - '/test.php', - $scriptUrl, - "'getScriptUrl()' should return 'SCRIPT_NAME' when adapter is 'null'.", - ); - } - public function testResetCookieCollectionAfterReset(): void { $this->mockWebApplication(); @@ -655,6 +633,42 @@ public function testReturnParentCsrfTokenFromHeaderWhenAdapterIsNull(): void ); } + public function testReturnParentGetParsedBodyWhenAdapterIsNull(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + // ensure adapter is 'null' (default state) + $request->reset(); + + self::assertEmpty( + $request->getParsedBody(), + "Parsed body should return empty array when 'PSR-7' request has no parsed body and adapter is 'null'.", + ); + } + public function testReturnParentGetScriptUrlWhenAdapterIsNull(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + // ensure adapter is 'null' (default state) + $request->reset(); + + $_SERVER['SCRIPT_NAME'] = '/test.php'; + $_SERVER['SCRIPT_FILENAME'] = '/path/to/test.php'; + + $scriptUrl = $request->getScriptUrl(); + + // kust verify the method executes without throwing exception when adapter is 'null' + self::assertSame( + '/test.php', + $scriptUrl, + "'getScriptUrl()' should return 'SCRIPT_NAME' when adapter is 'null'.", + ); + } + public function testReturnParentHttpMethodWhenAdapterIsNull(): void { $this->mockWebApplication(); diff --git a/tests/http/RequestTest.php b/tests/http/RequestTest.php index 8fbcf800..98115db8 100644 --- a/tests/http/RequestTest.php +++ b/tests/http/RequestTest.php @@ -1497,6 +1497,16 @@ public function testSetHostInfo(): void ); } + public function testThrowExceptionWhenAdapterPSR7IsNotSet(): void + { + $request = new Request(); + + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('PSR-7 request adapter is not set.'); + + $request->getPsr7Request(); + } + public function testThrowExceptionWhenRequestUriIsMissing(): void { $this->mockWebApplication(); From b0f314059c968acb5b658be97006b6cf410fd6f1 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sun, 20 Jul 2025 16:31:45 +0000 Subject: [PATCH 29/32] Apply fixes from StyleCI --- tests/http/PSR7RequestTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index bc95b189..65373a61 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -647,6 +647,7 @@ public function testReturnParentGetParsedBodyWhenAdapterIsNull(): void "Parsed body should return empty array when 'PSR-7' request has no parsed body and adapter is 'null'.", ); } + public function testReturnParentGetScriptUrlWhenAdapterIsNull(): void { $this->mockWebApplication(); From 08d84cf1ec16278e096418ff154683ddfb330a5c Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 12:44:20 -0400 Subject: [PATCH 30/32] feat(PSR7RequestTest): add test for read-only cookie collection when adapter is set. --- tests/http/PSR7RequestTest.php | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index 65373a61..96be27df 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -7,8 +7,10 @@ use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; use Psr\Http\Message\ServerRequestInterface; use Yii; +use yii\base\InvalidCallException; use yii\base\InvalidConfigException; use yii\helpers\Json; +use yii\web\Cookie; use yii\web\CookieCollection; use yii\web\UploadedFile; use yii2\extensions\psrbridge\http\Request; @@ -968,6 +970,46 @@ public function testReturnRawBodyWhenAdapterIsSetWithEmptyBody(): void ); } + public function testReturnReadOnlyCookieCollectionWhenAdapterIsSet(): void + { + $this->mockWebApplication(); + + $psr7Request = FactoryHelper::createRequest('GET', '/test'); + + $psr7Request = $psr7Request->withCookieParams( + [ + 'another_cookie' => 'another_value', + 'test_cookie' => 'test_value', + ], + ); + + $request = new Request(); + + $request->enableCookieValidation = false; + $request->cookieValidationKey = 'test-validation-key-32-characters'; + + $request->setPsr7Request($psr7Request); + $cookies = $request->getCookies(); + + self::assertInstanceOf( + CookieCollection::class, + $cookies, + "Cookies should return a 'CookieCollection' instance when adapter is set.", + ); + + $this->expectException(InvalidCallException::class); + $this->expectExceptionMessage('The cookie collection is read only.'); + + $cookies->add( + new Cookie( + [ + 'name' => 'new_cookie', + 'value' => 'new_value', + ], + ), + ); + } + public function testReturnScriptNameWhenAdapterIsSetInTraditionalMode(): void { $this->mockWebApplication(); From 62a7bd7cfb87d31ac231374cb8b494ff2c019e91 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 12:47:33 -0400 Subject: [PATCH 31/32] fix(PSR7RequestTest): correct string interpolation in uploaded file size assertion message. --- tests/http/PSR7RequestTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/PSR7RequestTest.php b/tests/http/PSR7RequestTest.php index 96be27df..923068ac 100644 --- a/tests/http/PSR7RequestTest.php +++ b/tests/http/PSR7RequestTest.php @@ -1234,7 +1234,7 @@ public function testReturnUploadedFilesWhenAdapterIsSet(): void self::assertSame( $expectedUpdloadedFiles[$name]['size'] ?? null, $uploadedFile->size, - "Uploaded file '{$name }' should have the expected size.", + "Uploaded file '{$name}' should have the expected size.", ); self::assertTrue( $uploadedFile->saveAs("{$runtimePath}/{$uploadedFile->name}", false), From 2e44a25db675d3f4e86cbc33286329a09c078932 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sun, 20 Jul 2025 12:52:02 -0400 Subject: [PATCH 32/32] chore: remove unused `test1.txt` file. --- test1.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 test1.txt diff --git a/test1.txt b/test1.txt deleted file mode 100644 index 10ddd6d2..00000000 --- a/test1.txt +++ /dev/null @@ -1 +0,0 @@ -Hello!