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