Merge "Special:BlockList: Update remove/change block links"
[mediawiki.git] / tests / phpunit / includes / libs / http / MultiHttpClientTest.php
blobb21488758ac149929e703a1d1f4dd78ace789bc6
1 <?php
3 use MediaWiki\Status\Status;
4 use PHPUnit\Framework\Constraint\IsType;
5 use PHPUnit\Framework\MockObject\MockObject;
6 use Wikimedia\Http\MultiHttpClient;
7 use Wikimedia\Http\TelemetryHeadersInterface;
8 use Wikimedia\TestingAccessWrapper;
10 /**
11 * The urls herein are not actually called, because we mock the return results.
13 * @covers \Wikimedia\Http\MultiHttpClient
15 class MultiHttpClientTest extends MediaWikiIntegrationTestCase {
16 /**
17 * @param array $options
18 * @return MultiHttpClient|MockObject
20 private function createClient( $options = [] ) {
21 $client = $this->getMockBuilder( MultiHttpClient::class )
22 ->setConstructorArgs( [ $options ] )
23 ->onlyMethods( [ 'isCurlEnabled' ] )->getMock();
24 $client->method( 'isCurlEnabled' )->willReturn( false );
25 return $client;
28 private function getHttpRequest( $statusValue, $statusCode, $headers = [] ) {
29 $options = [
30 'timeout' => 1,
31 'connectTimeout' => 1
33 $httpRequest = $this->getMockBuilder( MWHttpRequest::class )
34 ->setConstructorArgs( [ '', $options ] )
35 ->getMock();
36 $httpRequest->method( 'execute' )
37 ->willReturn( Status::wrap( $statusValue ) );
38 $httpRequest->method( 'getResponseHeaders' )
39 ->willReturn( $headers );
40 $httpRequest->method( 'getStatus' )
41 ->willReturn( $statusCode );
42 return $httpRequest;
45 private function mockHttpRequestFactory( $httpRequest ) {
46 $factory = $this->createMock( MediaWiki\Http\HttpRequestFactory::class );
47 $factory->method( 'create' )
48 ->willReturn( $httpRequest );
49 return $factory;
52 /**
53 * Test call of a single url that should succeed
55 public function testMultiHttpClientSingleSuccess() {
56 // Mock success
57 $httpRequest = $this->getHttpRequest( StatusValue::newGood( 200 ), 200 );
58 $this->setService( 'HttpRequestFactory', $this->mockHttpRequestFactory( $httpRequest ) );
60 [ $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ] = $this->createClient()->run( [
61 'method' => 'GET',
62 'url' => "http://example.test",
63 ] );
65 $this->assertSame( 200, $rcode );
68 /**
69 * Test call of a single url that should not exist, and therefore fail
71 public function testMultiHttpClientSingleFailure() {
72 // Mock an invalid tld
73 $httpRequest = $this->getHttpRequest(
74 StatusValue::newFatal( 'http-invalid-url', 'http://www.example.test' ), 0 );
75 $this->setService( 'HttpRequestFactory', $this->mockHttpRequestFactory( $httpRequest ) );
77 [ $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ] = $this->createClient()->run( [
78 'method' => 'GET',
79 'url' => "http://www.example.test",
80 ] );
82 $this->assertSame( 0, $rcode );
85 /**
86 * Test call of multiple urls that should all succeed
88 public function testMultiHttpClientMultipleSuccess() {
89 // Mock success
90 $httpRequest = $this->getHttpRequest( StatusValue::newGood( 200 ), 200 );
91 $this->setService( 'HttpRequestFactory', $this->mockHttpRequestFactory( $httpRequest ) );
93 $reqs = [
95 'method' => 'GET',
96 'url' => 'http://example.test',
99 'method' => 'GET',
100 'url' => 'https://get.test',
103 $responses = $this->createClient()->runMulti( $reqs );
104 foreach ( $responses as $response ) {
105 [ $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ] = $response['response'];
106 $this->assertSame( 200, $rcode );
111 * Test call of multiple urls that should all fail
113 public function testMultiHttpClientMultipleFailure() {
114 // Mock page not found
115 $httpRequest = $this->getHttpRequest(
116 StatusValue::newFatal( "http-bad-status", 404, 'Not Found' ), 404 );
117 $this->setService( 'HttpRequestFactory', $this->mockHttpRequestFactory( $httpRequest ) );
119 $reqs = [
121 'method' => 'GET',
122 'url' => 'http://example.test/12345',
125 'method' => 'GET',
126 'url' => 'http://example.test/67890',
129 $responses = $this->createClient()->runMulti( $reqs );
130 foreach ( $responses as $response ) {
131 [ $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ] = $response['response'];
132 $this->assertSame( 404, $rcode );
137 * Test of response header handling
139 public function testMultiHttpClientHeaders() {
140 // Represenative headers for typical requests, per MWHttpRequest::getResponseHeaders()
141 $headers = [
142 'content-type' => [
143 'text/html; charset=utf-8',
145 'date' => [
146 'Wed, 18 Jul 2018 14:52:41 GMT',
148 'set-cookie' => [
149 'COUNTRY=NAe6; expires=Wed, 25-Jul-2018 14:52:41 GMT; path=/; domain=.example.test',
150 'LAST_NEWS=1531925562; expires=Thu, 18-Jul-2019 14:52:41 GMT; path=/; domain=.example.test',
154 // Mock success with specific headers
155 $httpRequest = $this->getHttpRequest( StatusValue::newGood( 200 ), 200, $headers );
156 $this->setService( 'HttpRequestFactory', $this->mockHttpRequestFactory( $httpRequest ) );
158 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $this->createClient()->run( [
159 'method' => 'GET',
160 'url' => 'http://example.test',
161 ] );
163 $this->assertSame( 200, $rcode );
164 $this->assertSameSize( $headers, $rhdrs );
165 foreach ( $headers as $name => $values ) {
166 $value = implode( ', ', $values );
167 $this->assertArrayHasKey( $name, $rhdrs );
168 $this->assertEquals( $value, $rhdrs[$name] );
172 public static function provideMultiHttpTimeout() {
173 return [
174 'default 10/30' => [
180 'constructor override' => [
181 [ 'connTimeout' => 2, 'reqTimeout' => 3 ],
186 'run override' => [
188 [ 'connTimeout' => 2, 'reqTimeout' => 3 ],
192 'constructor max option limits default' => [
193 [ 'maxConnTimeout' => 2, 'maxReqTimeout' => 3 ],
198 'constructor max option limits regular constructor option' => [
200 'maxConnTimeout' => 2,
201 'maxReqTimeout' => 3,
202 'connTimeout' => 100,
203 'reqTimeout' => 100
209 'constructor max option greater than regular constructor option' => [
211 'maxConnTimeout' => 2,
212 'maxReqTimeout' => 3,
213 'connTimeout' => 1,
214 'reqTimeout' => 1
220 'constructor max option limits run option' => [
222 'maxConnTimeout' => 2,
223 'maxReqTimeout' => 3,
226 'connTimeout' => 100,
227 'reqTimeout' => 100
236 * Test of timeout parameter handling
237 * @dataProvider provideMultiHttpTimeout
239 public function testMultiHttpTimeout( $createOptions, $runOptions,
240 $expectedConnTimeout, $expectedReqTimeout
242 $url = 'http://www.example.test';
243 $httpRequest = $this->getHttpRequest( StatusValue::newGood( 200 ), 200 );
244 $factory = $this->createMock( MediaWiki\Http\HttpRequestFactory::class );
245 $factory->method( 'create' )
246 ->with(
247 $url,
248 $this->callback(
249 static function ( $options ) use ( $expectedReqTimeout, $expectedConnTimeout ) {
250 return $options['timeout'] === $expectedReqTimeout
251 && $options['connectTimeout'] === $expectedConnTimeout;
255 ->willReturn( $httpRequest );
256 $this->setService( 'HttpRequestFactory', $factory );
258 $client = $this->createClient( $createOptions );
260 $client->run(
261 [ 'method' => 'GET', 'url' => $url ],
262 $runOptions
265 $this->addToAssertionCount( 1 );
268 public function testUseReverseProxy() {
269 // TODO: Cannot use TestingAccessWrapper here because it doesn't
270 // support pass-by-reference (T287318)
271 $class = new ReflectionClass( MultiHttpClient::class );
272 $func = $class->getMethod( 'useReverseProxy' );
273 $func->setAccessible( true );
274 $req = [
275 'url' => 'https://example.org/path?query=string',
277 $func->invokeArgs( new MultiHttpClient( [] ), [ &$req, 'http://localhost:1234' ] );
278 $this->assertSame( 'http://localhost:1234/path?query=string', $req['url'] );
279 $this->assertSame( 'example.org', $req['headers']['Host'] );
282 public function testNormalizeRequests() {
283 // TODO: Cannot use TestingAccessWrapper here because it doesn't
284 // support pass-by-reference (T287318)
285 $class = new ReflectionClass( MultiHttpClient::class );
286 $func = $class->getMethod( 'normalizeRequests' );
287 $func->setAccessible( true );
288 $reqs = [
289 [ 'GET', 'https://example.org/path?query=string' ],
291 'method' => 'GET',
292 'url' => 'https://example.com/path?query=another%20string',
293 'headers' => [
294 'header2' => 'value2'
298 $client = new MultiHttpClient( [
299 'localVirtualHosts' => [ 'example.org' ],
300 'localProxy' => 'http://localhost:1234',
301 'headers' => [
302 'header1' => 'value1'
304 ] );
305 $func->invokeArgs( $client, [ &$reqs ] );
306 // Both requests have the default header added
307 $this->assertSame( 'value1', $reqs[0]['headers']['header1'] );
308 $this->assertSame( 'value1', $reqs[1]['headers']['header1'] );
309 // Only Req #1 has an additional header
310 $this->assertSame( 'value2', $reqs[1]['headers']['header2'] );
311 $this->assertArrayNotHasKey( 'header2', $reqs[0]['headers'] );
313 // Req #0 transformed to use reverse proxy
314 $this->assertSame( 'http://localhost:1234/path?query=string', $reqs[0]['url'] );
315 $this->assertSame( 'example.org', $reqs[0]['headers']['host'] );
316 $this->assertFalse( $reqs[0]['proxy'] );
317 // Req #1 left alone, domain doesn't match
318 $this->assertSame( 'https://example.com/path?query=another%20string', $reqs[1]['url'] );
322 * @dataProvider provideAssembleUrl
323 * @param array $bits
324 * @param string $expected
325 * @throws ReflectionException
327 public function testAssembleUrl( array $bits, string $expected ) {
328 $class = TestingAccessWrapper::newFromClass( MultiHttpClient::class );
329 $this->assertSame( $expected, $class->assembleUrl( $bits ) );
332 public static function provideAssembleUrl(): Generator {
333 $schemes = [
334 '' => [],
335 'http://' => [
336 'scheme' => 'http',
340 $hosts = [
341 '' => [],
342 'example.com' => [
343 'host' => 'example.com',
345 'example.com:123' => [
346 'host' => 'example.com',
347 'port' => 123,
349 'id@example.com' => [
350 'user' => 'id',
351 'host' => 'example.com',
353 'id@example.com:123' => [
354 'user' => 'id',
355 'host' => 'example.com',
356 'port' => 123,
358 'id:key@example.com' => [
359 'user' => 'id',
360 'pass' => 'key',
361 'host' => 'example.com',
363 'id:key@example.com:123' => [
364 'user' => 'id',
365 'pass' => 'key',
366 'host' => 'example.com',
367 'port' => 123,
371 foreach ( $schemes as $scheme => $schemeParts ) {
372 foreach ( $hosts as $host => $hostParts ) {
373 foreach ( [ '', '/', '/0', '/path' ] as $path ) {
374 foreach ( [ '', '0', 'query' ] as $query ) {
375 foreach ( [ '', '0', 'fragment' ] as $fragment ) {
376 $parts = array_merge(
377 $schemeParts,
378 $hostParts
380 $url = $scheme .
381 $host .
382 $path;
384 if ( $path !== '' ) {
385 $parts['path'] = $path;
387 if ( $query !== '' ) {
388 $parts['query'] = $query;
389 $url .= '?' . $query;
391 if ( $fragment !== '' ) {
392 $parts['fragment'] = $fragment;
393 $url .= '#' . $fragment;
396 yield [ $parts, $url ];
403 yield [
405 'scheme' => 'http',
406 'user' => 'id',
407 'pass' => 'key',
408 'host' => 'example.org',
409 'port' => 321,
410 'path' => '/over/there',
411 'query' => 'name=ferret&foo=bar',
412 'fragment' => 'nose',
414 'http://id:key@example.org:321/over/there?name=ferret&foo=bar#nose',
417 // Account for parse_url() on PHP >= 8 returning an empty query field for URLs ending with
418 // '?' such as "http://url.with.empty.query/foo?" (T268852)
419 yield [
421 'scheme' => 'http',
422 'host' => 'url.with.empty.query',
423 'path' => '/foo',
424 'query' => '',
426 'http://url.with.empty.query/foo',
430 public static function provideHeader() {
431 // Invalid
432 yield 'colon space' => [ false, [ 'Foo: X' => 'Y' ] ];
433 yield 'colon' => [ false, [ 'Foo:bar' => 'X' ] ];
434 yield 'two colon' => [ false, [ 'Foo:bar:baz' => 'X' ] ];
435 yield 'trailing colon' => [ false, [ 'Foo:' => 'Y' ] ];
436 yield 'leading colon' => [ false, [ ':Foo' => 'Y' ] ];
437 // Valid
438 yield 'word' => [ true, [ 'Foo' => 'X' ] ];
439 yield 'dash' => [ true, [ 'Foo-baz' => 'X' ] ];
443 * @dataProvider provideHeader
445 public function testNormalizeIllegalHeader( bool $valid, array $headers ) {
446 $class = new ReflectionClass( MultiHttpClient::class );
447 $func = $class->getMethod( 'getCurlHandle' );
448 $func->setAccessible( true );
449 $req = [
450 'method' => 'GET',
451 'url' => 'http://localhost:1234',
452 'query' => [],
453 'body' => '',
454 'headers' => $headers
457 if ( $valid ) {
458 $this->expectNotToPerformAssertions();
459 } else {
460 $this->expectException( Exception::class );
461 $this->expectExceptionMessage( 'Header name must not contain colon-space' );
463 $func->invokeArgs( new MultiHttpClient( [] ), [ &$req, [
464 'connTimeout' => 1,
465 'reqTimeout' => 1,
466 ] ] );
467 // TODO: Factor out curl_multi_exec so can stub that,
468 // and then simply test the public runMulti() method here.
469 // Or move more logic to normalizeRequests and test that.
472 public function testForwardsTelemetryHeaders() {
473 $telemetry = $this->getMockBuilder( TelemetryHeadersInterface::class )
474 ->getMock();
475 $telemetry->expects( $this->once() )
476 ->method( 'getRequestHeaders' )
477 ->willReturn( [ 'header1' => 'value1', 'header2' => 'value2' ] );
479 // TODO: Cannot use TestingAccessWrapper here because it doesn't
480 // support pass-by-reference (T287318)
481 $class = new ReflectionClass( MultiHttpClient::class );
482 $func = $class->getMethod( 'normalizeRequests' );
483 $func->setAccessible( true );
484 $reqs = [
485 [ 'GET', 'https://example.org/path?query=string' ],
487 $client = new MultiHttpClient( [
488 'localVirtualHosts' => [ 'example.org' ],
489 'localProxy' => 'http://localhost:1234',
490 'telemetry' => $telemetry
491 ] );
492 $func->invokeArgs( $client, [ &$reqs ] );
493 $this->assertArrayHasKey( 'header1', $reqs[0]['headers'] );
494 $this->assertSame( 'value1', $reqs[0]['headers']['header1'] );
495 $this->assertArrayHasKey( 'header2', $reqs[0]['headers'] );
496 $this->assertSame( 'value2', $reqs[0]['headers']['header2'] );
499 public function testGetCurlMulti() {
500 $cm = TestingAccessWrapper::newFromObject( new MultiHttpClient( [] ) );
501 $resource = $cm->getCurlMulti( [ 'usePipelining' => true ] );
502 $this->assertThat(
503 $resource,
504 $this->logicalOr(
505 $this->isType( IsType::TYPE_RESOURCE ),
506 $this->isInstanceOf( 'CurlMultiHandle' )