Merge ".pipeline/config.yaml: rename dev stage to publish"
[lhc/web/wiklou.git] / tests / phpunit / includes / api / ApiMainTest.php
1 <?php
2
3 use Wikimedia\Rdbms\DBQueryError;
4 use Wikimedia\TestingAccessWrapper;
5 use Wikimedia\Timestamp\ConvertibleTimestamp;
6
7 /**
8 * @group API
9 * @group Database
10 * @group medium
11 *
12 * @covers ApiMain
13 */
14 class ApiMainTest extends ApiTestCase {
15
16 /**
17 * Test that the API will accept a FauxRequest and execute.
18 */
19 public function testApi() {
20 $api = new ApiMain(
21 new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
22 );
23 $api->execute();
24 $data = $api->getResult()->getResultData();
25 $this->assertInternalType( 'array', $data );
26 $this->assertArrayHasKey( 'query', $data );
27 }
28
29 public function testApiNoParam() {
30 $api = new ApiMain();
31 $api->execute();
32 $data = $api->getResult()->getResultData();
33 $this->assertInternalType( 'array', $data );
34 }
35
36 /**
37 * ApiMain behaves differently if passed a FauxRequest (mInternalMode set
38 * to true) or a proper WebRequest (mInternalMode false). For most tests
39 * we can just set mInternalMode to false using TestingAccessWrapper, but
40 * this doesn't work for the constructor. This method returns an ApiMain
41 * that's been set up in non-internal mode.
42 *
43 * Note that calling execute() will print to the console. Wrap it in
44 * ob_start()/ob_end_clean() to prevent this.
45 *
46 * @param array $requestData Query parameters for the WebRequest
47 * @param array $headers Headers for the WebRequest
48 */
49 private function getNonInternalApiMain( array $requestData, array $headers = [] ) {
50 $req = $this->getMockBuilder( WebRequest::class )
51 ->setMethods( [ 'response', 'getRawIP' ] )
52 ->getMock();
53 $response = new FauxResponse();
54 $req->method( 'response' )->willReturn( $response );
55 $req->method( 'getRawIP' )->willReturn( '127.0.0.1' );
56
57 $wrapper = TestingAccessWrapper::newFromObject( $req );
58 $wrapper->data = $requestData;
59 if ( $headers ) {
60 $wrapper->headers = $headers;
61 }
62
63 return new ApiMain( $req );
64 }
65
66 public function testUselang() {
67 global $wgLang;
68
69 $api = $this->getNonInternalApiMain( [
70 'action' => 'query',
71 'meta' => 'siteinfo',
72 'uselang' => 'fr',
73 ] );
74
75 ob_start();
76 $api->execute();
77 ob_end_clean();
78
79 $this->assertSame( 'fr', $wgLang->getCode() );
80 }
81
82 public function testNonWhitelistedCorsWithCookies() {
83 $logFile = $this->getNewTempFile();
84
85 $this->mergeMwGlobalArrayValue( '_COOKIE', [ 'forceHTTPS' => '1' ] );
86 $logger = new TestLogger( true );
87 $this->setLogger( 'cors', $logger );
88
89 $api = $this->getNonInternalApiMain( [
90 'action' => 'query',
91 'meta' => 'siteinfo',
92 // For some reason multiple origins (which are not allowed in the
93 // WHATWG Fetch spec that supersedes the RFC) are always considered to
94 // be problematic.
95 ], [ 'ORIGIN' => 'https://www.example.com https://www.com.example' ] );
96
97 $this->assertSame(
98 [ [ Psr\Log\LogLevel::WARNING, 'Non-whitelisted CORS request with session cookies' ] ],
99 $logger->getBuffer()
100 );
101 }
102
103 public function testSuppressedLogin() {
104 global $wgUser;
105 $origUser = $wgUser;
106
107 $api = $this->getNonInternalApiMain( [
108 'action' => 'query',
109 'meta' => 'siteinfo',
110 'origin' => '*',
111 ] );
112
113 ob_start();
114 $api->execute();
115 ob_end_clean();
116
117 $this->assertNotSame( $origUser, $wgUser );
118 $this->assertSame( 'true', $api->getContext()->getRequest()->response()
119 ->getHeader( 'MediaWiki-Login-Suppressed' ) );
120 }
121
122 public function testSetContinuationManager() {
123 $api = new ApiMain();
124 $manager = $this->createMock( ApiContinuationManager::class );
125 $api->setContinuationManager( $manager );
126 $this->assertTrue( true, 'No exception' );
127 return [ $api, $manager ];
128 }
129
130 /**
131 * @depends testSetContinuationManager
132 */
133 public function testSetContinuationManagerTwice( $args ) {
134 $this->setExpectedException( UnexpectedValueException::class,
135 'ApiMain::setContinuationManager: tried to set manager from ' .
136 'when a manager is already set from ' );
137
138 list( $api, $manager ) = $args;
139 $api->setContinuationManager( $manager );
140 }
141
142 public function testSetCacheModeUnrecognized() {
143 $api = new ApiMain();
144 $api->setCacheMode( 'unrecognized' );
145 $this->assertSame(
146 'private',
147 TestingAccessWrapper::newFromObject( $api )->mCacheMode,
148 'Unrecognized params must be silently ignored'
149 );
150 }
151
152 public function testSetCacheModePrivateWiki() {
153 $this->setGroupPermissions( '*', 'read', false );
154 $wrappedApi = TestingAccessWrapper::newFromObject( new ApiMain() );
155 $wrappedApi->setCacheMode( 'public' );
156 $this->assertSame( 'private', $wrappedApi->mCacheMode );
157 $wrappedApi->setCacheMode( 'anon-public-user-private' );
158 $this->assertSame( 'private', $wrappedApi->mCacheMode );
159 }
160
161 public function testAddRequestedFieldsRequestId() {
162 $req = new FauxRequest( [
163 'action' => 'query',
164 'meta' => 'siteinfo',
165 'requestid' => '123456',
166 ] );
167 $api = new ApiMain( $req );
168 $api->execute();
169 $this->assertSame( '123456', $api->getResult()->getResultData()['requestid'] );
170 }
171
172 public function testAddRequestedFieldsCurTimestamp() {
173 // Fake timestamp for better testability, CI can sometimes take
174 // unreasonably long to run the simple test request here.
175 $reset = ConvertibleTimestamp::setFakeTime( '20190102030405' );
176
177 $req = new FauxRequest( [
178 'action' => 'query',
179 'meta' => 'siteinfo',
180 'curtimestamp' => '',
181 ] );
182 $api = new ApiMain( $req );
183 $api->execute();
184 $timestamp = $api->getResult()->getResultData()['curtimestamp'];
185 $this->assertSame( '2019-01-02T03:04:05Z', $timestamp );
186 }
187
188 public function testAddRequestedFieldsResponseLangInfo() {
189 $req = new FauxRequest( [
190 'action' => 'query',
191 'meta' => 'siteinfo',
192 // errorlang is ignored if errorformat is not specified
193 'errorformat' => 'plaintext',
194 'uselang' => 'FR',
195 'errorlang' => 'ja',
196 'responselanginfo' => '',
197 ] );
198 $api = new ApiMain( $req );
199 $api->execute();
200 $data = $api->getResult()->getResultData();
201 $this->assertSame( 'fr', $data['uselang'] );
202 $this->assertSame( 'ja', $data['errorlang'] );
203 }
204
205 public function testSetupModuleUnknown() {
206 $this->setExpectedException( ApiUsageException::class,
207 'Unrecognized value for parameter "action": unknownaction.' );
208
209 $req = new FauxRequest( [ 'action' => 'unknownaction' ] );
210 $api = new ApiMain( $req );
211 $api->execute();
212 }
213
214 public function testSetupModuleNoTokenProvided() {
215 $this->setExpectedException( ApiUsageException::class,
216 'The "token" parameter must be set.' );
217
218 $req = new FauxRequest( [
219 'action' => 'edit',
220 'title' => 'New page',
221 'text' => 'Some text',
222 ] );
223 $api = new ApiMain( $req );
224 $api->execute();
225 }
226
227 public function testSetupModuleInvalidTokenProvided() {
228 $this->setExpectedException( ApiUsageException::class, 'Invalid CSRF token.' );
229
230 $req = new FauxRequest( [
231 'action' => 'edit',
232 'title' => 'New page',
233 'text' => 'Some text',
234 'token' => "This isn't a real token!",
235 ] );
236 $api = new ApiMain( $req );
237 $api->execute();
238 }
239
240 public function testSetupModuleNeedsTokenTrue() {
241 $this->setExpectedException( MWException::class,
242 "Module 'testmodule' must be updated for the new token handling. " .
243 "See documentation for ApiBase::needsToken for details." );
244
245 $mock = $this->createMock( ApiBase::class );
246 $mock->method( 'getModuleName' )->willReturn( 'testmodule' );
247 $mock->method( 'needsToken' )->willReturn( true );
248
249 $api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) );
250 $api->getModuleManager()->addModule( 'testmodule', 'action', [
251 'class' => get_class( $mock ),
252 'factory' => function () use ( $mock ) {
253 return $mock;
254 }
255 ] );
256 $api->execute();
257 }
258
259 public function testSetupModuleNeedsTokenNeedntBePosted() {
260 $this->setExpectedException( MWException::class,
261 "Module 'testmodule' must require POST to use tokens." );
262
263 $mock = $this->createMock( ApiBase::class );
264 $mock->method( 'getModuleName' )->willReturn( 'testmodule' );
265 $mock->method( 'needsToken' )->willReturn( 'csrf' );
266 $mock->method( 'mustBePosted' )->willReturn( false );
267
268 $api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) );
269 $api->getModuleManager()->addModule( 'testmodule', 'action', [
270 'class' => get_class( $mock ),
271 'factory' => function () use ( $mock ) {
272 return $mock;
273 }
274 ] );
275 $api->execute();
276 }
277
278 public function testCheckMaxLagFailed() {
279 // It's hard to mock the LoadBalancer properly, so instead we'll mock
280 // checkMaxLag (which is tested directly in other tests below).
281 $req = new FauxRequest( [
282 'action' => 'query',
283 'meta' => 'siteinfo',
284 ] );
285
286 $mock = $this->getMockBuilder( ApiMain::class )
287 ->setConstructorArgs( [ $req ] )
288 ->setMethods( [ 'checkMaxLag' ] )
289 ->getMock();
290 $mock->method( 'checkMaxLag' )->willReturn( false );
291
292 $mock->execute();
293
294 $this->assertArrayNotHasKey( 'query', $mock->getResult()->getResultData() );
295 }
296
297 public function testCheckConditionalRequestHeadersFailed() {
298 // The detailed checking of all cases of checkConditionalRequestHeaders
299 // is below in testCheckConditionalRequestHeaders(), which calls the
300 // method directly. Here we just check that it will stop execution if
301 // it does fail.
302 $now = time();
303
304 $this->setMwGlobals( 'wgCacheEpoch', '20030516000000' );
305
306 $mock = $this->createMock( ApiBase::class );
307 $mock->method( 'getModuleName' )->willReturn( 'testmodule' );
308 $mock->method( 'getConditionalRequestData' )
309 ->willReturn( wfTimestamp( TS_MW, $now - 3600 ) );
310 $mock->expects( $this->exactly( 0 ) )->method( 'execute' );
311
312 $req = new FauxRequest( [
313 'action' => 'testmodule',
314 ] );
315 $req->setHeader( 'If-Modified-Since', wfTimestamp( TS_RFC2822, $now - 3600 ) );
316 $req->setRequestURL( "http://localhost" );
317
318 $api = new ApiMain( $req );
319 $api->getModuleManager()->addModule( 'testmodule', 'action', [
320 'class' => get_class( $mock ),
321 'factory' => function () use ( $mock ) {
322 return $mock;
323 }
324 ] );
325
326 $wrapper = TestingAccessWrapper::newFromObject( $api );
327 $wrapper->mInternalMode = false;
328
329 ob_start();
330 $api->execute();
331 ob_end_clean();
332 }
333
334 private function doTestCheckMaxLag( $lag ) {
335 $mockLB = $this->getMockBuilder( LoadBalancer::class )
336 ->disableOriginalConstructor()
337 ->setMethods( [ 'getMaxLag', '__destruct' ] )
338 ->getMock();
339 $mockLB->method( 'getMaxLag' )->willReturn( [ 'somehost', $lag ] );
340 $this->setService( 'DBLoadBalancer', $mockLB );
341
342 $req = new FauxRequest();
343
344 $api = new ApiMain( $req );
345 $wrapper = TestingAccessWrapper::newFromObject( $api );
346
347 $mockModule = $this->createMock( ApiBase::class );
348 $mockModule->method( 'shouldCheckMaxLag' )->willReturn( true );
349
350 try {
351 $wrapper->checkMaxLag( $mockModule, [ 'maxlag' => 3 ] );
352 } finally {
353 if ( $lag > 3 ) {
354 $this->assertSame( '5', $req->response()->getHeader( 'Retry-After' ) );
355 $this->assertSame( (string)$lag, $req->response()->getHeader( 'X-Database-Lag' ) );
356 }
357 }
358 }
359
360 public function testCheckMaxLagOkay() {
361 $this->doTestCheckMaxLag( 3 );
362
363 // No exception, we're happy
364 $this->assertTrue( true );
365 }
366
367 public function testCheckMaxLagExceeded() {
368 $this->setExpectedException( ApiUsageException::class,
369 'Waiting for a database server: 4 seconds lagged.' );
370
371 $this->setMwGlobals( 'wgShowHostnames', false );
372
373 $this->doTestCheckMaxLag( 4 );
374 }
375
376 public function testCheckMaxLagExceededWithHostNames() {
377 $this->setExpectedException( ApiUsageException::class,
378 'Waiting for somehost: 4 seconds lagged.' );
379
380 $this->setMwGlobals( 'wgShowHostnames', true );
381
382 $this->doTestCheckMaxLag( 4 );
383 }
384
385 public static function provideAssert() {
386 return [
387 [ false, [], 'user', 'assertuserfailed' ],
388 [ true, [], 'user', false ],
389 [ true, [], 'bot', 'assertbotfailed' ],
390 [ true, [ 'bot' ], 'user', false ],
391 [ true, [ 'bot' ], 'bot', false ],
392 ];
393 }
394
395 /**
396 * Tests the assert={user|bot} functionality
397 *
398 * @dataProvider provideAssert
399 * @param bool $registered
400 * @param array $rights
401 * @param string $assert
402 * @param string|bool $error False if no error expected
403 */
404 public function testAssert( $registered, $rights, $assert, $error ) {
405 if ( $registered ) {
406 $user = $this->getMutableTestUser()->getUser();
407 $user->load(); // load before setting mRights
408 } else {
409 $user = new User();
410 }
411 $this->overrideUserPermissions( $user, $rights );
412 try {
413 $this->doApiRequest( [
414 'action' => 'query',
415 'assert' => $assert,
416 ], null, null, $user );
417 $this->assertFalse( $error ); // That no error was expected
418 } catch ( ApiUsageException $e ) {
419 $this->assertTrue( self::apiExceptionHasCode( $e, $error ),
420 "Error '{$e->getMessage()}' matched expected '$error'" );
421 }
422 }
423
424 /**
425 * Tests the assertuser= functionality
426 */
427 public function testAssertUser() {
428 $user = $this->getTestUser()->getUser();
429 $this->doApiRequest( [
430 'action' => 'query',
431 'assertuser' => $user->getName(),
432 ], null, null, $user );
433
434 try {
435 $this->doApiRequest( [
436 'action' => 'query',
437 'assertuser' => $user->getName() . 'X',
438 ], null, null, $user );
439 $this->fail( 'Expected exception not thrown' );
440 } catch ( ApiUsageException $e ) {
441 $this->assertTrue( self::apiExceptionHasCode( $e, 'assertnameduserfailed' ) );
442 }
443 }
444
445 /**
446 * Test that 'assert' is processed before module errors
447 */
448 public function testAssertBeforeModule() {
449 // Sanity check that the query without assert throws too-many-titles
450 try {
451 $this->doApiRequest( [
452 'action' => 'query',
453 'titles' => implode( '|', range( 1, ApiBase::LIMIT_SML1 + 1 ) ),
454 ], null, null, new User );
455 $this->fail( 'Expected exception not thrown' );
456 } catch ( ApiUsageException $e ) {
457 $this->assertTrue( self::apiExceptionHasCode( $e, 'too-many-titles' ), 'sanity check' );
458 }
459
460 // Now test that the assert happens first
461 try {
462 $this->doApiRequest( [
463 'action' => 'query',
464 'titles' => implode( '|', range( 1, ApiBase::LIMIT_SML1 + 1 ) ),
465 'assert' => 'user',
466 ], null, null, new User );
467 $this->fail( 'Expected exception not thrown' );
468 } catch ( ApiUsageException $e ) {
469 $this->assertTrue( self::apiExceptionHasCode( $e, 'assertuserfailed' ),
470 "Error '{$e->getMessage()}' matched expected 'assertuserfailed'" );
471 }
472 }
473
474 /**
475 * Test if all classes in the main module manager exists
476 */
477 public function testClassNamesInModuleManager() {
478 $api = new ApiMain(
479 new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
480 );
481 $modules = $api->getModuleManager()->getNamesWithClasses();
482
483 foreach ( $modules as $name => $class ) {
484 $this->assertTrue(
485 class_exists( $class ),
486 'Class ' . $class . ' for api module ' . $name . ' does not exist (with exact case)'
487 );
488 }
489 }
490
491 /**
492 * Test HTTP precondition headers
493 *
494 * @dataProvider provideCheckConditionalRequestHeaders
495 * @param array $headers HTTP headers
496 * @param array $conditions Return data for ApiBase::getConditionalRequestData
497 * @param int $status Expected response status
498 * @param array $options Array of options:
499 * post => true Request is a POST
500 * cdn => true CDN is enabled ($wgUseCdn)
501 */
502 public function testCheckConditionalRequestHeaders(
503 $headers, $conditions, $status, $options = []
504 ) {
505 $request = new FauxRequest(
506 [ 'action' => 'query', 'meta' => 'siteinfo' ],
507 !empty( $options['post'] )
508 );
509 $request->setHeaders( $headers );
510 $request->response()->statusHeader( 200 ); // Why doesn't it default?
511
512 $context = $this->apiContext->newTestContext( $request, null );
513 $api = new ApiMain( $context );
514 $priv = TestingAccessWrapper::newFromObject( $api );
515 $priv->mInternalMode = false;
516
517 if ( !empty( $options['cdn'] ) ) {
518 $this->setMwGlobals( 'wgUseCdn', true );
519 }
520
521 // Can't do this in TestSetup.php because Setup.php will override it
522 $this->setMwGlobals( 'wgCacheEpoch', '20030516000000' );
523
524 $module = $this->getMockBuilder( ApiBase::class )
525 ->setConstructorArgs( [ $api, 'mock' ] )
526 ->setMethods( [ 'getConditionalRequestData' ] )
527 ->getMockForAbstractClass();
528 $module->expects( $this->any() )
529 ->method( 'getConditionalRequestData' )
530 ->will( $this->returnCallback( function ( $condition ) use ( $conditions ) {
531 return $conditions[$condition] ?? null;
532 } ) );
533
534 $ret = $priv->checkConditionalRequestHeaders( $module );
535
536 $this->assertSame( $status, $request->response()->getStatusCode() );
537 $this->assertSame( $status === 200, $ret );
538 }
539
540 public static function provideCheckConditionalRequestHeaders() {
541 global $wgCdnMaxAge;
542 $now = time();
543
544 return [
545 // Non-existing from module is ignored
546 'If-None-Match' => [ [ 'If-None-Match' => '"foo", "bar"' ], [], 200 ],
547 'If-Modified-Since' =>
548 [ [ 'If-Modified-Since' => 'Tue, 18 Aug 2015 00:00:00 GMT' ], [], 200 ],
549
550 // No headers
551 'No headers' => [ [], [ 'etag' => '""', 'last-modified' => '20150815000000', ], 200 ],
552
553 // Basic If-None-Match
554 'If-None-Match with matching etag' =>
555 [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 304 ],
556 'If-None-Match with non-matching etag' =>
557 [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"baz"' ], 200 ],
558 'Strong If-None-Match with weak matching etag' =>
559 [ [ 'If-None-Match' => '"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ],
560 'Weak If-None-Match with strong matching etag' =>
561 [ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => '"foo"' ], 304 ],
562 'Weak If-None-Match with weak matching etag' =>
563 [ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ],
564
565 // Pointless for GET, but supported
566 'If-None-Match: *' => [ [ 'If-None-Match' => '*' ], [], 304 ],
567
568 // Basic If-Modified-Since
569 'If-Modified-Since, modified one second earlier' =>
570 [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
571 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
572 'If-Modified-Since, modified now' =>
573 [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
574 [ 'last-modified' => wfTimestamp( TS_MW, $now ) ], 304 ],
575 'If-Modified-Since, modified one second later' =>
576 [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
577 [ 'last-modified' => wfTimestamp( TS_MW, $now + 1 ) ], 200 ],
578
579 // If-Modified-Since ignored when If-None-Match is given too
580 'Non-matching If-None-Match and matching If-Modified-Since' =>
581 [ [ 'If-None-Match' => '""',
582 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
583 [ 'etag' => '"x"', 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
584 'Non-matching If-None-Match and matching If-Modified-Since with no ETag' =>
585 [
586 [
587 'If-None-Match' => '""',
588 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now )
589 ],
590 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ],
591 304
592 ],
593
594 // Ignored for POST
595 'Matching If-None-Match with POST' =>
596 [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 200,
597 [ 'post' => true ] ],
598 'Matching If-Modified-Since with POST' =>
599 [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
600 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200,
601 [ 'post' => true ] ],
602
603 // Other date formats allowed by the RFC
604 'If-Modified-Since with alternate date format 1' =>
605 [ [ 'If-Modified-Since' => gmdate( 'l, d-M-y H:i:s', $now ) . ' GMT' ],
606 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
607 'If-Modified-Since with alternate date format 2' =>
608 [ [ 'If-Modified-Since' => gmdate( 'D M j H:i:s Y', $now ) ],
609 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
610
611 // Old browser extension to HTTP/1.0
612 'If-Modified-Since with length' =>
613 [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) . '; length=123' ],
614 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
615
616 // Invalid date formats should be ignored
617 'If-Modified-Since with invalid date format' =>
618 [ [ 'If-Modified-Since' => gmdate( 'Y-m-d H:i:s', $now ) . ' GMT' ],
619 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
620 'If-Modified-Since with entirely unparseable date' =>
621 [ [ 'If-Modified-Since' => 'a potato' ],
622 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
623
624 // Anything before $wgCdnMaxAge seconds ago should be considered
625 // expired.
626 'If-Modified-Since with CDN post-expiry' =>
627 [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgCdnMaxAge * 2 ) ],
628 [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgCdnMaxAge * 3 ) ],
629 200, [ 'cdn' => true ] ],
630 'If-Modified-Since with CDN pre-expiry' =>
631 [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgCdnMaxAge / 2 ) ],
632 [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgCdnMaxAge * 3 ) ],
633 304, [ 'cdn' => true ] ],
634 ];
635 }
636
637 /**
638 * Test conditional headers output
639 * @dataProvider provideConditionalRequestHeadersOutput
640 * @param array $conditions Return data for ApiBase::getConditionalRequestData
641 * @param array $headers Expected output headers
642 * @param bool $isError $isError flag
643 * @param bool $post Request is a POST
644 */
645 public function testConditionalRequestHeadersOutput(
646 $conditions, $headers, $isError = false, $post = false
647 ) {
648 $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ], $post );
649 $response = $request->response();
650
651 $api = new ApiMain( $request );
652 $priv = TestingAccessWrapper::newFromObject( $api );
653 $priv->mInternalMode = false;
654
655 $module = $this->getMockBuilder( ApiBase::class )
656 ->setConstructorArgs( [ $api, 'mock' ] )
657 ->setMethods( [ 'getConditionalRequestData' ] )
658 ->getMockForAbstractClass();
659 $module->expects( $this->any() )
660 ->method( 'getConditionalRequestData' )
661 ->will( $this->returnCallback( function ( $condition ) use ( $conditions ) {
662 return $conditions[$condition] ?? null;
663 } ) );
664 $priv->mModule = $module;
665
666 $priv->sendCacheHeaders( $isError );
667
668 foreach ( [ 'Last-Modified', 'ETag' ] as $header ) {
669 $this->assertEquals(
670 $headers[$header] ?? null,
671 $response->getHeader( $header ),
672 $header
673 );
674 }
675 }
676
677 public static function provideConditionalRequestHeadersOutput() {
678 return [
679 [
680 [],
681 []
682 ],
683 [
684 [ 'etag' => '"foo"' ],
685 [ 'ETag' => '"foo"' ]
686 ],
687 [
688 [ 'last-modified' => '20150818000102' ],
689 [ 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
690 ],
691 [
692 [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
693 [ 'ETag' => '"foo"', 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
694 ],
695 [
696 [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
697 [],
698 true,
699 ],
700 [
701 [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
702 [],
703 false,
704 true,
705 ],
706 ];
707 }
708
709 public function testCheckExecutePermissionsReadProhibited() {
710 $this->setExpectedException( ApiUsageException::class,
711 'You need read permission to use this module.' );
712
713 $this->setGroupPermissions( '*', 'read', false );
714
715 $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
716 $main->execute();
717 }
718
719 public function testCheckExecutePermissionWriteDisabled() {
720 $this->setExpectedException( ApiUsageException::class,
721 'Editing of this wiki through the API is disabled. Make sure the ' .
722 '"$wgEnableWriteAPI=true;" statement is included in the wiki\'s ' .
723 '"LocalSettings.php" file.' );
724 $main = new ApiMain( new FauxRequest( [
725 'action' => 'edit',
726 'title' => 'Some page',
727 'text' => 'Some text',
728 'token' => '+\\',
729 ] ) );
730 $main->execute();
731 }
732
733 public function testCheckExecutePermissionWriteApiProhibited() {
734 $this->setExpectedException( ApiUsageException::class,
735 "You're not allowed to edit this wiki through the API." );
736 $this->setGroupPermissions( '*', 'writeapi', false );
737
738 $main = new ApiMain( new FauxRequest( [
739 'action' => 'edit',
740 'title' => 'Some page',
741 'text' => 'Some text',
742 'token' => '+\\',
743 ] ), /* enableWrite = */ true );
744 $main->execute();
745 }
746
747 public function testCheckExecutePermissionPromiseNonWrite() {
748 $this->setExpectedException( ApiUsageException::class,
749 'The "Promise-Non-Write-API-Action" HTTP header cannot be sent ' .
750 'to write-mode API modules.' );
751
752 $req = new FauxRequest( [
753 'action' => 'edit',
754 'title' => 'Some page',
755 'text' => 'Some text',
756 'token' => '+\\',
757 ] );
758 $req->setHeaders( [ 'Promise-Non-Write-API-Action' => '1' ] );
759 $main = new ApiMain( $req, /* enableWrite = */ true );
760 $main->execute();
761 }
762
763 public function testCheckExecutePermissionHookAbort() {
764 $this->setExpectedException( ApiUsageException::class, 'Main Page' );
765
766 $this->setTemporaryHook( 'ApiCheckCanExecute', function ( $unused1, $unused2, &$message ) {
767 $message = 'mainpage';
768 return false;
769 } );
770
771 $main = new ApiMain( new FauxRequest( [
772 'action' => 'edit',
773 'title' => 'Some page',
774 'text' => 'Some text',
775 'token' => '+\\',
776 ] ), /* enableWrite = */ true );
777 $main->execute();
778 }
779
780 public function testGetValUnsupportedArray() {
781 $main = new ApiMain( new FauxRequest( [
782 'action' => 'query',
783 'meta' => 'siteinfo',
784 'siprop' => [ 'general', 'namespaces' ],
785 ] ) );
786 $this->assertSame( 'myDefault', $main->getVal( 'siprop', 'myDefault' ) );
787 $main->execute();
788 $this->assertSame( 'Parameter "siprop" uses unsupported PHP array syntax.',
789 $main->getResult()->getResultData()['warnings']['main']['warnings'] );
790 }
791
792 public function testReportUnusedParams() {
793 $main = new ApiMain( new FauxRequest( [
794 'action' => 'query',
795 'meta' => 'siteinfo',
796 'unusedparam' => 'unusedval',
797 'anotherunusedparam' => 'anotherval',
798 ] ) );
799 $main->execute();
800 $this->assertSame( 'Unrecognized parameters: unusedparam, anotherunusedparam.',
801 $main->getResult()->getResultData()['warnings']['main']['warnings'] );
802 }
803
804 public function testLacksSameOriginSecurity() {
805 // Basic test
806 $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
807 $this->assertFalse( $main->lacksSameOriginSecurity(), 'Basic test, should have security' );
808
809 // JSONp
810 $main = new ApiMain(
811 new FauxRequest( [ 'action' => 'query', 'format' => 'xml', 'callback' => 'foo' ] )
812 );
813 $this->assertTrue( $main->lacksSameOriginSecurity(), 'JSONp, should lack security' );
814
815 // Header
816 $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] );
817 $request->setHeader( 'TrEaT-As-UnTrUsTeD', '' ); // With falsey value!
818 $main = new ApiMain( $request );
819 $this->assertTrue( $main->lacksSameOriginSecurity(), 'Header supplied, should lack security' );
820
821 // Hook
822 $this->mergeMwGlobalArrayValue( 'wgHooks', [
823 'RequestHasSameOriginSecurity' => [ function () {
824 return false;
825 } ]
826 ] );
827 $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
828 $this->assertTrue( $main->lacksSameOriginSecurity(), 'Hook, should lack security' );
829 }
830
831 /**
832 * Test proper creation of the ApiErrorFormatter
833 *
834 * @dataProvider provideApiErrorFormatterCreation
835 * @param array $request Request parameters
836 * @param array $expect Expected data
837 * - uselang: ApiMain language
838 * - class: ApiErrorFormatter class
839 * - lang: ApiErrorFormatter language
840 * - format: ApiErrorFormatter format
841 * - usedb: ApiErrorFormatter use-database flag
842 */
843 public function testApiErrorFormatterCreation( array $request, array $expect ) {
844 $context = new RequestContext();
845 $context->setRequest( new FauxRequest( $request ) );
846 $context->setLanguage( 'ru' );
847
848 $main = new ApiMain( $context );
849 $formatter = $main->getErrorFormatter();
850 $wrappedFormatter = TestingAccessWrapper::newFromObject( $formatter );
851
852 $this->assertSame( $expect['uselang'], $main->getLanguage()->getCode() );
853 $this->assertInstanceOf( $expect['class'], $formatter );
854 $this->assertSame( $expect['lang'], $formatter->getLanguage()->getCode() );
855 $this->assertSame( $expect['format'], $wrappedFormatter->format );
856 $this->assertSame( $expect['usedb'], $wrappedFormatter->useDB );
857 }
858
859 public static function provideApiErrorFormatterCreation() {
860 return [
861 'Default (BC)' => [ [], [
862 'uselang' => 'ru',
863 'class' => ApiErrorFormatter_BackCompat::class,
864 'lang' => 'en',
865 'format' => 'none',
866 'usedb' => false,
867 ] ],
868 'BC ignores fields' => [ [ 'errorlang' => 'de', 'errorsuselocal' => 1 ], [
869 'uselang' => 'ru',
870 'class' => ApiErrorFormatter_BackCompat::class,
871 'lang' => 'en',
872 'format' => 'none',
873 'usedb' => false,
874 ] ],
875 'Explicit BC' => [ [ 'errorformat' => 'bc' ], [
876 'uselang' => 'ru',
877 'class' => ApiErrorFormatter_BackCompat::class,
878 'lang' => 'en',
879 'format' => 'none',
880 'usedb' => false,
881 ] ],
882 'Basic' => [ [ 'errorformat' => 'wikitext' ], [
883 'uselang' => 'ru',
884 'class' => ApiErrorFormatter::class,
885 'lang' => 'ru',
886 'format' => 'wikitext',
887 'usedb' => false,
888 ] ],
889 'Follows uselang' => [ [ 'uselang' => 'fr', 'errorformat' => 'plaintext' ], [
890 'uselang' => 'fr',
891 'class' => ApiErrorFormatter::class,
892 'lang' => 'fr',
893 'format' => 'plaintext',
894 'usedb' => false,
895 ] ],
896 'Explicitly follows uselang' => [
897 [ 'uselang' => 'fr', 'errorlang' => 'uselang', 'errorformat' => 'plaintext' ],
898 [
899 'uselang' => 'fr',
900 'class' => ApiErrorFormatter::class,
901 'lang' => 'fr',
902 'format' => 'plaintext',
903 'usedb' => false,
904 ]
905 ],
906 'uselang=content' => [
907 [ 'uselang' => 'content', 'errorformat' => 'plaintext' ],
908 [
909 'uselang' => 'en',
910 'class' => ApiErrorFormatter::class,
911 'lang' => 'en',
912 'format' => 'plaintext',
913 'usedb' => false,
914 ]
915 ],
916 'errorlang=content' => [
917 [ 'errorlang' => 'content', 'errorformat' => 'plaintext' ],
918 [
919 'uselang' => 'ru',
920 'class' => ApiErrorFormatter::class,
921 'lang' => 'en',
922 'format' => 'plaintext',
923 'usedb' => false,
924 ]
925 ],
926 'Explicit parameters' => [
927 [ 'errorlang' => 'de', 'errorformat' => 'html', 'errorsuselocal' => 1 ],
928 [
929 'uselang' => 'ru',
930 'class' => ApiErrorFormatter::class,
931 'lang' => 'de',
932 'format' => 'html',
933 'usedb' => true,
934 ]
935 ],
936 'Explicit parameters override uselang' => [
937 [ 'errorlang' => 'de', 'uselang' => 'fr', 'errorformat' => 'raw' ],
938 [
939 'uselang' => 'fr',
940 'class' => ApiErrorFormatter::class,
941 'lang' => 'de',
942 'format' => 'raw',
943 'usedb' => false,
944 ]
945 ],
946 'Bogus language doesn\'t explode' => [
947 [ 'errorlang' => '<bogus1>', 'uselang' => '<bogus2>', 'errorformat' => 'none' ],
948 [
949 'uselang' => 'en',
950 'class' => ApiErrorFormatter::class,
951 'lang' => 'en',
952 'format' => 'none',
953 'usedb' => false,
954 ]
955 ],
956 'Bogus format doesn\'t explode' => [ [ 'errorformat' => 'bogus' ], [
957 'uselang' => 'ru',
958 'class' => ApiErrorFormatter_BackCompat::class,
959 'lang' => 'en',
960 'format' => 'none',
961 'usedb' => false,
962 ] ],
963 ];
964 }
965
966 /**
967 * @dataProvider provideExceptionErrors
968 * @param Exception $exception
969 * @param array $expectReturn
970 * @param array $expectResult
971 */
972 public function testExceptionErrors( $error, $expectReturn, $expectResult ) {
973 $context = new RequestContext();
974 $context->setRequest( new FauxRequest( [ 'errorformat' => 'plaintext' ] ) );
975 $context->setLanguage( 'en' );
976 $context->setConfig( new MultiConfig( [
977 new HashConfig( [
978 'ShowHostnames' => true, 'ShowExceptionDetails' => true,
979 ] ),
980 $context->getConfig()
981 ] ) );
982
983 $main = new ApiMain( $context );
984 $main->addWarning( new RawMessage( 'existing warning' ), 'existing-warning' );
985 $main->addError( new RawMessage( 'existing error' ), 'existing-error' );
986
987 $ret = TestingAccessWrapper::newFromObject( $main )->substituteResultWithError( $error );
988 $this->assertSame( $expectReturn, $ret );
989
990 // PHPUnit sometimes adds some SplObjectStorage garbage to the arrays,
991 // so let's try ->assertEquals().
992 $this->assertEquals(
993 $expectResult,
994 $main->getResult()->getResultData( [], [ 'Strip' => 'all' ] )
995 );
996 }
997
998 // Not static so $this can be used
999 public function provideExceptionErrors() {
1000 $reqId = WebRequest::getRequestId();
1001 $doclink = wfExpandUrl( wfScript( 'api' ) );
1002
1003 $ex = new InvalidArgumentException( 'Random exception' );
1004 $trace = wfMessage( 'api-exception-trace',
1005 get_class( $ex ),
1006 $ex->getFile(),
1007 $ex->getLine(),
1008 MWExceptionHandler::getRedactedTraceAsString( $ex )
1009 )->inLanguage( 'en' )->useDatabase( false )->text();
1010
1011 $dbex = new DBQueryError(
1012 $this->createMock( \Wikimedia\Rdbms\IDatabase::class ),
1013 'error', 1234, 'SELECT 1', __METHOD__ );
1014 $dbtrace = wfMessage( 'api-exception-trace',
1015 get_class( $dbex ),
1016 $dbex->getFile(),
1017 $dbex->getLine(),
1018 MWExceptionHandler::getRedactedTraceAsString( $dbex )
1019 )->inLanguage( 'en' )->useDatabase( false )->text();
1020
1021 // The specific exception doesn't matter, as long as it's namespaced.
1022 $nsex = new MediaWiki\ShellDisabledError();
1023 $nstrace = wfMessage( 'api-exception-trace',
1024 get_class( $nsex ),
1025 $nsex->getFile(),
1026 $nsex->getLine(),
1027 MWExceptionHandler::getRedactedTraceAsString( $nsex )
1028 )->inLanguage( 'en' )->useDatabase( false )->text();
1029
1030 $apiEx1 = new ApiUsageException( null,
1031 StatusValue::newFatal( new ApiRawMessage( 'An error', 'sv-error1' ) ) );
1032 TestingAccessWrapper::newFromObject( $apiEx1 )->modulePath = 'foo+bar';
1033 $apiEx1->getStatusValue()->warning( new ApiRawMessage( 'A warning', 'sv-warn1' ) );
1034 $apiEx1->getStatusValue()->warning( new ApiRawMessage( 'Another warning', 'sv-warn2' ) );
1035 $apiEx1->getStatusValue()->fatal( new ApiRawMessage( 'Another error', 'sv-error2' ) );
1036
1037 $badMsg = $this->getMockBuilder( ApiRawMessage::class )
1038 ->setConstructorArgs( [ 'An error', 'ignored' ] )
1039 ->setMethods( [ 'getApiCode' ] )
1040 ->getMock();
1041 $badMsg->method( 'getApiCode' )->willReturn( "bad\nvalue" );
1042 $apiEx2 = new ApiUsageException( null, StatusValue::newFatal( $badMsg ) );
1043
1044 return [
1045 [
1046 $ex,
1047 [ 'existing-error', 'internal_api_error_InvalidArgumentException' ],
1048 [
1049 'warnings' => [
1050 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
1051 ],
1052 'errors' => [
1053 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
1054 [
1055 'code' => 'internal_api_error_InvalidArgumentException',
1056 'text' => "[$reqId] Exception caught: Random exception",
1057 'data' => [
1058 'errorclass' => InvalidArgumentException::class,
1059 ],
1060 ]
1061 ],
1062 'trace' => $trace,
1063 'servedby' => wfHostname(),
1064 ]
1065 ],
1066 [
1067 $dbex,
1068 [ 'existing-error', 'internal_api_error_DBQueryError' ],
1069 [
1070 'warnings' => [
1071 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
1072 ],
1073 'errors' => [
1074 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
1075 [
1076 'code' => 'internal_api_error_DBQueryError',
1077 'text' => "[$reqId] Exception caught: A database query error has occurred. " .
1078 "This may indicate a bug in the software.",
1079 'data' => [
1080 'errorclass' => DBQueryError::class,
1081 ],
1082 ]
1083 ],
1084 'trace' => $dbtrace,
1085 'servedby' => wfHostname(),
1086 ]
1087 ],
1088 [
1089 $nsex,
1090 [ 'existing-error', 'internal_api_error_MediaWiki\ShellDisabledError' ],
1091 [
1092 'warnings' => [
1093 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
1094 ],
1095 'errors' => [
1096 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
1097 [
1098 'code' => 'internal_api_error_MediaWiki\ShellDisabledError',
1099 'text' => "[$reqId] Exception caught: " . $nsex->getMessage(),
1100 'data' => [
1101 'errorclass' => MediaWiki\ShellDisabledError::class,
1102 ],
1103 ]
1104 ],
1105 'trace' => $nstrace,
1106 'servedby' => wfHostname(),
1107 ]
1108 ],
1109 [
1110 $apiEx1,
1111 [ 'existing-error', 'sv-error1', 'sv-error2' ],
1112 [
1113 'warnings' => [
1114 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
1115 [ 'code' => 'sv-warn1', 'text' => 'A warning', 'module' => 'foo+bar' ],
1116 [ 'code' => 'sv-warn2', 'text' => 'Another warning', 'module' => 'foo+bar' ],
1117 ],
1118 'errors' => [
1119 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
1120 [ 'code' => 'sv-error1', 'text' => 'An error', 'module' => 'foo+bar' ],
1121 [ 'code' => 'sv-error2', 'text' => 'Another error', 'module' => 'foo+bar' ],
1122 ],
1123 'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
1124 "list at &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; " .
1125 "for notice of API deprecations and breaking changes.",
1126 'servedby' => wfHostname(),
1127 ]
1128 ],
1129 [
1130 $apiEx2,
1131 [ 'existing-error', '<invalid-code>' ],
1132 [
1133 'warnings' => [
1134 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
1135 ],
1136 'errors' => [
1137 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
1138 [ 'code' => "bad\nvalue", 'text' => 'An error' ],
1139 ],
1140 'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
1141 "list at &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; " .
1142 "for notice of API deprecations and breaking changes.",
1143 'servedby' => wfHostname(),
1144 ]
1145 ]
1146 ];
1147 }
1148
1149 public function testPrinterParameterValidationError() {
1150 $api = $this->getNonInternalApiMain( [
1151 'action' => 'query', 'meta' => 'siteinfo', 'format' => 'json', 'formatversion' => 'bogus',
1152 ] );
1153
1154 ob_start();
1155 $api->execute();
1156 $txt = ob_get_clean();
1157
1158 // Test that the actual output is valid JSON, not just the format of the ApiResult.
1159 $data = FormatJson::decode( $txt, true );
1160 $this->assertInternalType( 'array', $data );
1161 $this->assertArrayHasKey( 'error', $data );
1162 $this->assertArrayHasKey( 'code', $data['error'] );
1163 $this->assertSame( 'unknown_formatversion', $data['error']['code'] );
1164 }
1165 }