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 getCookies(bool $enableValidation = false, string $validationKey = ''): array + { + return $enableValidation + ? $this->getValidatedCookies($validationKey) + : $this->getSimpleCookies(); + } + + 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 $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 + if ($this->psrRequest->hasHeader('X-Http-Method-Override')) { + $overrideHeader = $this->psrRequest->getHeaderLine('X-Http-Method-Override'); + + if ($overrideHeader !== '') { + return strtoupper($overrideHeader); + } + } + + return $this->psrRequest->getMethod(); + } + + /** + * @phpstan-return array|object|null + */ + public function getParsedBody(): array|object|null + { + return $this->psrRequest->getParsedBody(); + } + + /** + * @phpstan-return array + */ + public function getQueryParams(): array + { + 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(bool $workerMode): string + { + $serverParams = $this->psrRequest->getServerParams(); + + // 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, FrankenPHP, etc.) where no script file exists + // return empty to prevent URL duplication as routing is handled internally + return ''; + } + + /** + * @phpstan-return array + */ + public function getUploadedFiles(): array + { + return $this->psrRequest->getUploadedFiles(); + } + + public function getUrl(): string + { + $uri = $this->psrRequest->getUri(); + $url = $uri->getPath(); + + if ($uri->getQuery() !== '') { + $url .= '?' . $uri->getQuery(); + } + + return $url; + } + + /** + * @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; + } +} diff --git a/src/emitter/SapiEmitter.php b/src/emitter/SapiEmitter.php index c13efd77..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(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..9f905a7f --- /dev/null +++ b/src/http/Request.php @@ -0,0 +1,213 @@ +|object + */ + public function getBodyParams(): array|object + { + if ($this->adapter !== null) { + return $this->adapter->getBodyParams($this->methodParam); + } + + return parent::getBodyParams(); + } + + public function getCookies(): CookieCollection + { + if ($this->adapter !== null) { + $cookies = $this->adapter->getCookies($this->enableCookieValidation, $this->cookieValidationKey); + + return new CookieCollection($cookies, ['readOnly' => true]); + } + + return parent::getCookies(); + } + + 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->getMethod($this->methodParam); + } + + return parent::getMethod(); + } + + /** + * @phpstan-return array|object|null + */ + public function getParsedBody(): array|object|null + { + if ($this->adapter !== null) { + return $this->adapter->getParsedBody(); + } + + return parent::getBodyParams(); + } + + public function getPsr7Request(): ServerRequestInterface + { + if ($this->adapter === null) { + throw new InvalidConfigException('PSR-7 request adapter is not set.'); + } + + return $this->adapter->psrRequest; + } + + /** + * @phpstan-return array + */ + public function getQueryParams(): array + { + if ($this->adapter !== null) { + return $this->adapter->getQueryParams(); + } + + 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) { + 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($this->workerMode); + } + + return parent::getScriptUrl(); + } + + /** + * @phpstan-return array + */ + public function getUploadedFiles(): array + { + if ($this->adapter !== null) { + return $this->convertPsr7ToUploadedFiles($this->adapter->getUploadedFiles()); + } + + return []; + } + + public function getUrl(): string + { + if ($this->adapter !== null) { + return $this->adapter->getUrl(); + } + + return parent::getUrl(); + } + + public function reset(): void + { + $this->adapter = null; + } + + 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() ?? '', + ], + ); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..556a1fe7 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,96 @@ + + */ + 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 ConsoleApplication( + 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 WebApplication( + 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 @@ +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 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(); + + $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 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 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 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(); + + $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(); + + $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 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 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(); + + $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 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."); + } + + public function testReturnParentQueryStringWhenAdapterIsNull(): void + { + $this->mockWebApplication(); + + $request = new Request(); + + $queryString = $request->getQueryString(); + + self::assertEmpty( + $queryString, + "Query string should be empty when 'PSR-7' request has no query string and adapter is 'null'.", + ); + } + + 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 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(); + + $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(); + + $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 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.", + ); + } + + /** + * @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 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 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(); + + $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 testReturnUploadedFilesRecursivelyConvertsNestedArrays(): void + { + $this->mockWebApplication(); + + $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'; + $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}/{$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.", + ); + } + } + + #[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(); + + $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(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.", + ); + } +} diff --git a/tests/http/RequestTest.php b/tests/http/RequestTest.php new file mode 100644 index 00000000..98115db8 --- /dev/null +++ b/tests/http/RequestTest.php @@ -0,0 +1,1579 @@ + $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 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(); + + $_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.', + ); + } + + 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 + */ + #[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 + { + $originalCookie = $_COOKIE; + + $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.", + ); + + $_COOKIE = $originalCookie; + } + + 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()'.", + ); + } + + 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(); + + $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 + * + * @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..7bb6003c --- /dev/null +++ b/tests/provider/RequestProvider.php @@ -0,0 +1,933 @@ +, + * 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 + */ + 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 + */ + 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}> + */ + 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, + ], + ]; + } +} 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 @@ +