/
githubmirror
/
symfony
Обзор
Документация
Войти
/
githubmirror
/
symfony
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
8.2
src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php
442 строки
17 KB
Nicolas Grekas
Merge branch '8.0' into 8.1
29 июл 2026, 10:22
29 июл 2026, 10:22
f1f5ce7
Код
Авторство
О чём код?
<?php /* * This file is part of the Symfony package. * * (c) Fabien Potencier <fabien@symfony.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\HttpClient\Tests; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Symfony\Component\HttpClient\CurlHttpClient; use Symfony\Component\HttpClient\Exception\InvalidArgumentException; use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Component\HttpClient\Internal\CurlClientState; #[RequiresPhpExtension('curl')] #[Group('dns-sensitive')] class CurlHttpClientTest extends HttpClientTestCase { private const HTTPS_PROXY_ERROR = 'Cannot use an "https://" proxy: the installed curl does not support HTTPS proxies and could connect to it in cleartext; curl 7.52 or higher is required.'; protected function getHttpClient(string $testCase): CurlHttpClient { $usePersistentConnections = str_contains($testCase, 'Persistent'); if (!str_contains($testCase, 'Push')) { return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false, 'extra' => ['use_persistent_connections' => $usePersistentConnections]]); } if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073D00 > ($v = curl_version())['version_number'] || !(\CURL_VERSION_HTTP2 & $v['features'])) { $this->markTestSkipped('curl <7.61 is used or it is not compiled with support for HTTP/2 PUSH'); } return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false, 'extra' => ['use_persistent_connections' => $usePersistentConnections]], 6, 50); } public function testTimeoutIsNotAFatalError() { if ('\\' === \DIRECTORY_SEPARATOR) { $this->markTestSkipped('Too transient on Windows'); } parent::testTimeoutIsNotAFatalError(); } public function testHandleIsReinitOnReset() { $httpClient = $this->getHttpClient(__FUNCTION__); $r = new \ReflectionProperty($httpClient, 'multi'); $clientState = $r->getValue($httpClient); $initialShareId = $clientState->share; $httpClient->reset(); self::assertNotSame($initialShareId, $clientState->share); } public function testCurlClientStateIsSharedBetweenClones() { $client = $this->getHttpClient(__FUNCTION__); $cloneA = $client->withOptions(['headers' => ['Foo: bar']]); $cloneB = $client->withOptions(['headers' => ['Foo: baz']]); $r = new \ReflectionProperty($client, 'multi'); $state = $r->getValue($client); self::assertSame($state, $r->getValue($cloneA)); self::assertSame($state, $r->getValue($cloneB)); } public function testCurlClientStateInitializesHandlesLazily() { $client = $this->getHttpClient(__FUNCTION__); $r = new \ReflectionProperty($client, 'multi'); $state = $r->getValue($client); self::assertFalse(isset($state->handle)); self::assertFalse(isset($state->share)); self::assertFalse(isset($state->persistentShare)); $client->request('GET', 'http://127.0.0.1:8057/json')->getStatusCode(); self::assertInstanceOf(\CurlMultiHandle::class, $state->handle); self::assertInstanceOf(\CurlShareHandle::class, $state->share); self::assertFalse(isset($state->persistentShare)); } public function testCurlClientPersistentStateInitializesHandlesLazily() { $client = $this->getHttpClient(__FUNCTION__); $r = new \ReflectionProperty($client, 'multi'); $state = $r->getValue($client); self::assertFalse(isset($state->handle)); self::assertFalse(isset($state->share)); self::assertFalse(isset($state->persistentShare)); $client->request('GET', 'http://127.0.0.1:8057/json')->getStatusCode(); self::assertInstanceOf(\CurlMultiHandle::class, $state->handle); if (\PHP_VERSION_ID >= 80500) { self::assertFalse(isset($state->share)); self::assertInstanceOf(\CurlSharePersistentHandle::class, $state->persistentShare); } else { self::assertInstanceOf(\CurlShareHandle::class, $state->share); self::assertSame($state->share, $state->persistentShare); } } public function testProcessAfterReset() { $client = $this->getHttpClient(__FUNCTION__); $response = $client->request('GET', 'http://127.0.0.1:8057/json'); $client->reset(); $this->assertSame(['application/json'], $response->getHeaders()['content-type']); } public function testOverridingRefererUsingCurlOptions() { $httpClient = $this->getHttpClient(__FUNCTION__); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cannot set "CURLOPT_REFERER" with "extra.curl", use option "headers" instead.'); $httpClient->request('GET', 'http://localhost:8057/', [ 'extra' => [ 'curl' => [ \CURLOPT_REFERER => 'Banana', ], ], ]); } public function testOverridingHttpMethodUsingCurlOptions() { $httpClient = $this->getHttpClient(__FUNCTION__); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The HTTP method cannot be overridden using "extra.curl".'); $httpClient->request('POST', 'http://localhost:8057/', [ 'extra' => [ 'curl' => [ \CURLOPT_HTTPGET => true, ], ], ]); } public function testOverridingInternalAttributesUsingCurlOptions() { $httpClient = $this->getHttpClient(__FUNCTION__); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cannot set "CURLOPT_PRIVATE" with "extra.curl".'); $httpClient->request('POST', 'http://localhost:8057/', [ 'extra' => [ 'curl' => [ \CURLOPT_PRIVATE => 'overridden private', ], ], ]); } public function testOverridingMaxConnectDurationUsingCurlOptions() { $httpClient = $this->getHttpClient(__FUNCTION__); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cannot set "CURLOPT_CONNECTTIMEOUT_MS" with "extra.curl", use option "max_connect_duration" instead.'); $httpClient->request('GET', 'http://localhost:8057/', [ 'extra' => [ 'curl' => [ \CURLOPT_CONNECTTIMEOUT_MS => 5000, ], ], ]); } public function testHttpsProxyIsRejectedWhenCurlLacksSupport() { $client = $this->getHttpClient(__FUNCTION__); $this->withProxyEnvironment([], [], false, function () use ($client) { $this->expectException(TransportException::class); $this->expectExceptionMessage(self::HTTPS_PROXY_ERROR); $client->request('GET', 'http://127.0.0.1:8057/', ['proxy' => 'https://127.0.0.1:8057']); }); } public function testHttpsProxyFromServerVarsIsRejectedWhenCurlLacksSupport() { $client = $this->getHttpClient(__FUNCTION__); $this->withProxyEnvironment(['https_proxy' => 'https://127.0.0.1:8057'], [], false, function () use ($client) { $this->expectException(TransportException::class); $this->expectExceptionMessage(self::HTTPS_PROXY_ERROR); $client->request('GET', 'https://127.0.0.1:8057/'); }); } public function testProxyFromProcessEnvIsNotUsed() { $client = $this->getHttpClient(__FUNCTION__); // Only curl reads the process environment, and it must not do so behind our back $this->withProxyEnvironment([], ['http_proxy' => 'http://127.0.0.1:9'], true, function () use ($client) { $response = $client->request('GET', 'http://127.0.0.1:8057/'); $this->assertSame(200, $response->getStatusCode()); }); } public function testHttpsProxyIsRejectedOnRedirectWhenCurlLacksSupport() { $client = $this->getHttpClient(__FUNCTION__); $this->withProxyEnvironment(['https_proxy' => 'https://127.0.0.1:8057'], [], false, function () use ($client) { $response = $client->request('GET', 'http://127.0.0.1:8057/302?location=https://127.0.0.1:8057/'); $this->expectException(TransportException::class); $this->expectExceptionMessage(self::HTTPS_PROXY_ERROR); $response->getStatusCode(); }); } public function testHttpsProxyIsNotRejectedWhenNoProxyMatches() { $client = $this->getHttpClient(__FUNCTION__); $this->withProxyEnvironment([], [], false, function () use ($client) { $response = $client->request('GET', 'http://127.0.0.1:8057/', [ 'proxy' => 'https://127.0.0.1:8057', 'no_proxy' => '127.0.0.1', ]); $this->assertSame(200, $response->getStatusCode()); }); } public function testHttpsProxyIsNotRejectedWhenCurlSupportsIt() { $client = $this->getHttpClient(__FUNCTION__); $this->withProxyEnvironment([], [], true, function () use ($client) { $response = $client->request('GET', 'http://127.0.0.1:8057/', ['proxy' => 'https://127.0.0.1:8057']); $response->cancel(); $this->addToAssertionCount(1); }); } public function testKeepAuthorizationHeaderOnRedirectToSameHostWithConfiguredHostToIpAddressMapping() { $httpClient = $this->getHttpClient(__FUNCTION__); $response = $httpClient->request('POST', 'http://127.0.0.1:8057/301', [ 'headers' => [ 'Authorization' => 'Basic Zm9vOmJhcg==', ], 'resolve' => [ 'symfony.com' => '10.10.10.10', ], ]); $this->assertSame(200, $response->getStatusCode()); $this->assertSame('/302', $response->toArray()['REQUEST_URI'] ?? null); } public function testNtlmRequiresFreshConnectionStateIsEmptyByDefault() { $client = $this->getHttpClient(__FUNCTION__); $r = new \ReflectionProperty($client, 'multi'); $state = $r->getValue($client); self::assertSame([], $state->ntlmRequiresFreshConnection); } public function testNtlmFreshConnectionForcedWhenOriginKnown() { $client = $this->getHttpClient(__FUNCTION__); $client->request('GET', 'http://127.0.0.1:8057/')->getContent(); $r = new \ReflectionProperty($client, 'multi'); $state = $r->getValue($client); $state->ntlmRequiresFreshConnection['http://127.0.0.1:8057'] = true; $response = $client->request('GET', 'http://127.0.0.1:8057/', [ 'auth_ntlm' => 'user:pass', ]); $response->getStatusCode(); self::assertStringNotContainsString('Re-using existing connection', $response->getInfo('debug')); } public function testNtlmStateNotMutatedByNonNtlmRequest() { // A plain (non-NTLM) request must not touch the NTLM origin map. This guards against // a regression where the detection logic fires on every CURLMSG_DONE event (e.g. an // inverted condition guard) — which would silently mark every origin as needing a // fresh NTLM connection. $client = $this->getHttpClient(__FUNCTION__); $client->request('GET', 'http://127.0.0.1:8057/json')->getContent(); $r = new \ReflectionProperty($client, 'multi'); $state = $r->getValue($client); self::assertSame([], $state->ntlmRequiresFreshConnection); } public function testNtlmStateNotMutatedByFreshConnectionNtlm401() { // A 401 + NTLM challenge that arrives on a fresh connection (NUM_CONNECTS != 0) is // the legitimate first leg of libcurl's in-request handshake — not the cross-request // de-auth case. Detection must NOT fire, and the origin must NOT be marked. $client = $this->getHttpClient(__FUNCTION__); $client->request('GET', 'http://127.0.0.1:8057/custom?'.http_build_query([ 'status' => 401, 'headers' => ['WWW-Authenticate: NTLM TlRMTVNTUAACAAAAAwADADgAAAAGgokCB7m5ksVjjAsAAAAAAAAAAHYAdgA7AAAACgB8TwAAAA9QUkQ=', 'Content-Length: 0'], ]), [ 'auth_ntlm' => 'user:pass', ])->getStatusCode(); $r = new \ReflectionProperty($client, 'multi'); $state = $r->getValue($client); self::assertSame([], $state->ntlmRequiresFreshConnection); } public function testNoNtlmLogMessagesForNonNtlmRequest() { // The observable signal for "we discarded a connection" is a specific log line. // A plain request must not produce that line — protects against bugs that cause // the retry/discovery path to fire unconditionally. $client = $this->getHttpClient(__FUNCTION__); $logger = new TestLogger(); $client->setLogger($logger); $client->request('GET', 'http://127.0.0.1:8057/json')->getContent(); $ntlmLogs = array_filter($logger->logs, static fn ($msg) => str_contains($msg, 'NTLM')); self::assertSame([], $ntlmLogs); } public function testNtlmLoopGuardDoesNotRetryWhenOriginAlreadyKnown() { $client = $this->getHttpClient(__FUNCTION__); $r = new \ReflectionProperty($client, 'multi'); $state = $r->getValue($client); $state->ntlmRequiresFreshConnection['http://127.0.0.1:8057'] = true; $response = $client->request('GET', 'http://127.0.0.1:8057/custom?'.http_build_query([ 'status' => 401, 'headers' => ['WWW-Authenticate: NTLM TlRMTVNTUAACAAAAAwADADgAAAAGgokCB7m5ksVjjAsAAAAAAAAAAHYAdgA7AAAACgB8TwAAAA9QUkQ=', 'Content-Length: 0'], ]), [ 'auth_ntlm' => 'user:pass', ]); self::assertSame(401, $response->getStatusCode()); } #[Group('integration')] public function testMaxConnections() { foreach ($ports = [80, 8681, 8682, 8683, 8684] as $port) { if (!($fp = @fsockopen('localhost', $port, $errorCode, $errorMessage, 2))) { self::markTestSkipped('FrankenPHP is not running'); } fclose($fp); } $httpClient = $this->getHttpClient(__FUNCTION__); $expectedResults = [ [false, false, false, false, false], [true, true, true, true, true], [true, true, true, true, true], ]; foreach ($expectedResults as $expectedResult) { foreach ($ports as $i => $port) { $response = $httpClient->request('GET', \sprintf('http://localhost:%s/http-client', $port)); $response->getContent(); self::assertSame($expectedResult[$i], str_contains($response->getInfo('debug'), 'Re-using existing connection')); } } } /** * Runs $test with a controlled proxy environment and a curl that pretends to support HTTPS proxies or not. */ private function withProxyEnvironment(array $server, array $env, bool $httpsProxySupport, \Closure $test): void { $backup = []; foreach (['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY', 'no_proxy', 'NO_PROXY'] as $name) { $backup[$name] = [\array_key_exists($name, $_SERVER) ? $_SERVER[$name] : null, getenv($name, true)]; unset($_SERVER[$name]); putenv($name); } foreach ($server as $name => $value) { $_SERVER[$name] = $value; } foreach ($env as $name => $value) { putenv($name.'='.$value); } $curlVersion = CurlClientState::$curlVersion ?? curl_version(); $httpsProxyFeature = \defined('CURL_VERSION_HTTPS_PROXY') ? \CURL_VERSION_HTTPS_PROXY : 1 << 21; CurlClientState::$curlVersion = ['features' => $httpsProxySupport ? $curlVersion['features'] | $httpsProxyFeature : $curlVersion['features'] & ~$httpsProxyFeature] + $curlVersion; try { $test(); } finally { CurlClientState::$curlVersion = $curlVersion; foreach ($backup as $name => [$serverValue, $envValue]) { if (null === $serverValue) { unset($_SERVER[$name]); } else { $_SERVER[$name] = $serverValue; } false === $envValue ? putenv($name) : putenv($name.'='.$envValue); } } } }