3 use Wikimedia\Rdbms\DBQueryError
;
4 use Wikimedia\TestingAccessWrapper
;
5 use Wikimedia\Timestamp\ConvertibleTimestamp
;
14 class ApiMainTest
extends ApiTestCase
{
17 * Test that the API will accept a FauxRequest and execute.
19 public function testApi() {
21 new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
24 $data = $api->getResult()->getResultData();
25 $this->assertInternalType( 'array', $data );
26 $this->assertArrayHasKey( 'query', $data );
29 public function testApiNoParam() {
32 $data = $api->getResult()->getResultData();
33 $this->assertInternalType( 'array', $data );
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.
43 * Note that calling execute() will print to the console. Wrap it in
44 * ob_start()/ob_end_clean() to prevent this.
46 * @param array $requestData Query parameters for the WebRequest
47 * @param array $headers Headers for the WebRequest
49 private function getNonInternalApiMain( array $requestData, array $headers = [] ) {
50 $req = $this->getMockBuilder( WebRequest
::class )
51 ->setMethods( [ 'response', 'getRawIP' ] )
53 $response = new FauxResponse();
54 $req->method( 'response' )->willReturn( $response );
55 $req->method( 'getRawIP' )->willReturn( '127.0.0.1' );
57 $wrapper = TestingAccessWrapper
::newFromObject( $req );
58 $wrapper->data
= $requestData;
60 $wrapper->headers
= $headers;
63 return new ApiMain( $req );
66 public function testUselang() {
69 $api = $this->getNonInternalApiMain( [
79 $this->assertSame( 'fr', $wgLang->getCode() );
82 public function testNonWhitelistedCorsWithCookies() {
83 $logFile = $this->getNewTempFile();
85 $this->mergeMwGlobalArrayValue( '_COOKIE', [ 'forceHTTPS' => '1' ] );
86 $logger = new TestLogger( true );
87 $this->setLogger( 'cors', $logger );
89 $api = $this->getNonInternalApiMain( [
92 // For some reason multiple origins (which are not allowed in the
93 // WHATWG Fetch spec that supersedes the RFC) are always considered to
95 ], [ 'ORIGIN' => 'https://www.example.com https://www.com.example' ] );
98 [ [ Psr\Log\LogLevel
::WARNING
, 'Non-whitelisted CORS request with session cookies' ] ],
103 public function testSuppressedLogin() {
107 $api = $this->getNonInternalApiMain( [
109 'meta' => 'siteinfo',
117 $this->assertNotSame( $origUser, $wgUser );
118 $this->assertSame( 'true', $api->getContext()->getRequest()->response()
119 ->getHeader( 'MediaWiki-Login-Suppressed' ) );
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 ];
131 * @depends testSetContinuationManager
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 ' );
138 list( $api, $manager ) = $args;
139 $api->setContinuationManager( $manager );
142 public function testSetCacheModeUnrecognized() {
143 $api = new ApiMain();
144 $api->setCacheMode( 'unrecognized' );
147 TestingAccessWrapper
::newFromObject( $api )->mCacheMode
,
148 'Unrecognized params must be silently ignored'
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
);
161 public function testAddRequestedFieldsRequestId() {
162 $req = new FauxRequest( [
164 'meta' => 'siteinfo',
165 'requestid' => '123456',
167 $api = new ApiMain( $req );
169 $this->assertSame( '123456', $api->getResult()->getResultData()['requestid'] );
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' );
177 $req = new FauxRequest( [
179 'meta' => 'siteinfo',
180 'curtimestamp' => '',
182 $api = new ApiMain( $req );
184 $timestamp = $api->getResult()->getResultData()['curtimestamp'];
185 $this->assertSame( '2019-01-02T03:04:05Z', $timestamp );
188 public function testAddRequestedFieldsResponseLangInfo() {
189 $req = new FauxRequest( [
191 'meta' => 'siteinfo',
192 // errorlang is ignored if errorformat is not specified
193 'errorformat' => 'plaintext',
196 'responselanginfo' => '',
198 $api = new ApiMain( $req );
200 $data = $api->getResult()->getResultData();
201 $this->assertSame( 'fr', $data['uselang'] );
202 $this->assertSame( 'ja', $data['errorlang'] );
205 public function testSetupModuleUnknown() {
206 $this->setExpectedException( ApiUsageException
::class,
207 'Unrecognized value for parameter "action": unknownaction.' );
209 $req = new FauxRequest( [ 'action' => 'unknownaction' ] );
210 $api = new ApiMain( $req );
214 public function testSetupModuleNoTokenProvided() {
215 $this->setExpectedException( ApiUsageException
::class,
216 'The "token" parameter must be set.' );
218 $req = new FauxRequest( [
220 'title' => 'New page',
221 'text' => 'Some text',
223 $api = new ApiMain( $req );
227 public function testSetupModuleInvalidTokenProvided() {
228 $this->setExpectedException( ApiUsageException
::class, 'Invalid CSRF token.' );
230 $req = new FauxRequest( [
232 'title' => 'New page',
233 'text' => 'Some text',
234 'token' => "This isn't a real token!",
236 $api = new ApiMain( $req );
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." );
245 $mock = $this->createMock( ApiBase
::class );
246 $mock->method( 'getModuleName' )->willReturn( 'testmodule' );
247 $mock->method( 'needsToken' )->willReturn( true );
249 $api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) );
250 $api->getModuleManager()->addModule( 'testmodule', 'action', [
251 'class' => get_class( $mock ),
252 'factory' => function () use ( $mock ) {
259 public function testSetupModuleNeedsTokenNeedntBePosted() {
260 $this->setExpectedException( MWException
::class,
261 "Module 'testmodule' must require POST to use tokens." );
263 $mock = $this->createMock( ApiBase
::class );
264 $mock->method( 'getModuleName' )->willReturn( 'testmodule' );
265 $mock->method( 'needsToken' )->willReturn( 'csrf' );
266 $mock->method( 'mustBePosted' )->willReturn( false );
268 $api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) );
269 $api->getModuleManager()->addModule( 'testmodule', 'action', [
270 'class' => get_class( $mock ),
271 'factory' => function () use ( $mock ) {
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( [
283 'meta' => 'siteinfo',
286 $mock = $this->getMockBuilder( ApiMain
::class )
287 ->setConstructorArgs( [ $req ] )
288 ->setMethods( [ 'checkMaxLag' ] )
290 $mock->method( 'checkMaxLag' )->willReturn( false );
294 $this->assertArrayNotHasKey( 'query', $mock->getResult()->getResultData() );
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
304 $this->setMwGlobals( 'wgCacheEpoch', '20030516000000' );
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' );
312 $req = new FauxRequest( [
313 'action' => 'testmodule',
315 $req->setHeader( 'If-Modified-Since', wfTimestamp( TS_RFC2822
, $now - 3600 ) );
316 $req->setRequestURL( "http://localhost" );
318 $api = new ApiMain( $req );
319 $api->getModuleManager()->addModule( 'testmodule', 'action', [
320 'class' => get_class( $mock ),
321 'factory' => function () use ( $mock ) {
326 $wrapper = TestingAccessWrapper
::newFromObject( $api );
327 $wrapper->mInternalMode
= false;
334 private function doTestCheckMaxLag( $lag ) {
335 $mockLB = $this->getMockBuilder( LoadBalancer
::class )
336 ->disableOriginalConstructor()
337 ->setMethods( [ 'getMaxLag', '__destruct' ] )
339 $mockLB->method( 'getMaxLag' )->willReturn( [ 'somehost', $lag ] );
340 $this->setService( 'DBLoadBalancer', $mockLB );
342 $req = new FauxRequest();
344 $api = new ApiMain( $req );
345 $wrapper = TestingAccessWrapper
::newFromObject( $api );
347 $mockModule = $this->createMock( ApiBase
::class );
348 $mockModule->method( 'shouldCheckMaxLag' )->willReturn( true );
351 $wrapper->checkMaxLag( $mockModule, [ 'maxlag' => 3 ] );
354 $this->assertSame( '5', $req->response()->getHeader( 'Retry-After' ) );
355 $this->assertSame( (string)$lag, $req->response()->getHeader( 'X-Database-Lag' ) );
360 public function testCheckMaxLagOkay() {
361 $this->doTestCheckMaxLag( 3 );
363 // No exception, we're happy
364 $this->assertTrue( true );
367 public function testCheckMaxLagExceeded() {
368 $this->setExpectedException( ApiUsageException
::class,
369 'Waiting for a database server: 4 seconds lagged.' );
371 $this->setMwGlobals( 'wgShowHostnames', false );
373 $this->doTestCheckMaxLag( 4 );
376 public function testCheckMaxLagExceededWithHostNames() {
377 $this->setExpectedException( ApiUsageException
::class,
378 'Waiting for somehost: 4 seconds lagged.' );
380 $this->setMwGlobals( 'wgShowHostnames', true );
382 $this->doTestCheckMaxLag( 4 );
385 public static function provideAssert() {
387 [ false, [], 'user', 'assertuserfailed' ],
388 [ true, [], 'user', false ],
389 [ true, [], 'bot', 'assertbotfailed' ],
390 [ true, [ 'bot' ], 'user', false ],
391 [ true, [ 'bot' ], 'bot', false ],
396 * Tests the assert={user|bot} functionality
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
404 public function testAssert( $registered, $rights, $assert, $error ) {
406 $user = $this->getMutableTestUser()->getUser();
407 $user->load(); // load before setting mRights
411 $this->overrideUserPermissions( $user, $rights );
413 $this->doApiRequest( [
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'" );
425 * Tests the assertuser= functionality
427 public function testAssertUser() {
428 $user = $this->getTestUser()->getUser();
429 $this->doApiRequest( [
431 'assertuser' => $user->getName(),
432 ], null, null, $user );
435 $this->doApiRequest( [
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' ) );
446 * Test that 'assert' is processed before module errors
448 public function testAssertBeforeModule() {
449 // Sanity check that the query without assert throws too-many-titles
451 $this->doApiRequest( [
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' );
460 // Now test that the assert happens first
462 $this->doApiRequest( [
464 'titles' => implode( '|', range( 1, ApiBase
::LIMIT_SML1 +
1 ) ),
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'" );
475 * Test if all classes in the main module manager exists
477 public function testClassNamesInModuleManager() {
479 new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
481 $modules = $api->getModuleManager()->getNamesWithClasses();
483 foreach ( $modules as $name => $class ) {
485 class_exists( $class ),
486 'Class ' . $class . ' for api module ' . $name . ' does not exist (with exact case)'
492 * Test HTTP precondition headers
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)
502 public function testCheckConditionalRequestHeaders(
503 $headers, $conditions, $status, $options = []
505 $request = new FauxRequest(
506 [ 'action' => 'query', 'meta' => 'siteinfo' ],
507 !empty( $options['post'] )
509 $request->setHeaders( $headers );
510 $request->response()->statusHeader( 200 ); // Why doesn't it default?
512 $context = $this->apiContext
->newTestContext( $request, null );
513 $api = new ApiMain( $context );
514 $priv = TestingAccessWrapper
::newFromObject( $api );
515 $priv->mInternalMode
= false;
517 if ( !empty( $options['cdn'] ) ) {
518 $this->setMwGlobals( 'wgUseCdn', true );
521 // Can't do this in TestSetup.php because Setup.php will override it
522 $this->setMwGlobals( 'wgCacheEpoch', '20030516000000' );
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;
534 $ret = $priv->checkConditionalRequestHeaders( $module );
536 $this->assertSame( $status, $request->response()->getStatusCode() );
537 $this->assertSame( $status === 200, $ret );
540 public static function provideCheckConditionalRequestHeaders() {
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 ],
551 'No headers' => [ [], [ 'etag' => '""', 'last-modified' => '20150815000000', ], 200 ],
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 ],
565 // Pointless for GET, but supported
566 'If-None-Match: *' => [ [ 'If-None-Match' => '*' ], [], 304 ],
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 ],
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' =>
587 'If-None-Match' => '""',
588 'If-Modified-Since' => wfTimestamp( TS_RFC2822
, $now )
590 [ 'last-modified' => wfTimestamp( TS_MW
, $now - 1 ) ],
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 ] ],
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 ],
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 ],
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 ],
624 // Anything before $wgCdnMaxAge seconds ago should be considered
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 ] ],
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
645 public function testConditionalRequestHeadersOutput(
646 $conditions, $headers, $isError = false, $post = false
648 $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ], $post );
649 $response = $request->response();
651 $api = new ApiMain( $request );
652 $priv = TestingAccessWrapper
::newFromObject( $api );
653 $priv->mInternalMode
= false;
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;
664 $priv->mModule
= $module;
666 $priv->sendCacheHeaders( $isError );
668 foreach ( [ 'Last-Modified', 'ETag' ] as $header ) {
670 $headers[$header] ??
null,
671 $response->getHeader( $header ),
677 public static function provideConditionalRequestHeadersOutput() {
684 [ 'etag' => '"foo"' ],
685 [ 'ETag' => '"foo"' ]
688 [ 'last-modified' => '20150818000102' ],
689 [ 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
692 [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
693 [ 'ETag' => '"foo"', 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
696 [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
701 [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
709 public function testCheckExecutePermissionsReadProhibited() {
710 $this->setExpectedException( ApiUsageException
::class,
711 'You need read permission to use this module.' );
713 $this->setGroupPermissions( '*', 'read', false );
715 $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
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( [
726 'title' => 'Some page',
727 'text' => 'Some text',
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 );
738 $main = new ApiMain( new FauxRequest( [
740 'title' => 'Some page',
741 'text' => 'Some text',
743 ] ), /* enableWrite = */ true );
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.' );
752 $req = new FauxRequest( [
754 'title' => 'Some page',
755 'text' => 'Some text',
758 $req->setHeaders( [ 'Promise-Non-Write-API-Action' => '1' ] );
759 $main = new ApiMain( $req, /* enableWrite = */ true );
763 public function testCheckExecutePermissionHookAbort() {
764 $this->setExpectedException( ApiUsageException
::class, 'Main Page' );
766 $this->setTemporaryHook( 'ApiCheckCanExecute', function ( $unused1, $unused2, &$message ) {
767 $message = 'mainpage';
771 $main = new ApiMain( new FauxRequest( [
773 'title' => 'Some page',
774 'text' => 'Some text',
776 ] ), /* enableWrite = */ true );
780 public function testGetValUnsupportedArray() {
781 $main = new ApiMain( new FauxRequest( [
783 'meta' => 'siteinfo',
784 'siprop' => [ 'general', 'namespaces' ],
786 $this->assertSame( 'myDefault', $main->getVal( 'siprop', 'myDefault' ) );
788 $this->assertSame( 'Parameter "siprop" uses unsupported PHP array syntax.',
789 $main->getResult()->getResultData()['warnings']['main']['warnings'] );
792 public function testReportUnusedParams() {
793 $main = new ApiMain( new FauxRequest( [
795 'meta' => 'siteinfo',
796 'unusedparam' => 'unusedval',
797 'anotherunusedparam' => 'anotherval',
800 $this->assertSame( 'Unrecognized parameters: unusedparam, anotherunusedparam.',
801 $main->getResult()->getResultData()['warnings']['main']['warnings'] );
804 public function testLacksSameOriginSecurity() {
806 $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
807 $this->assertFalse( $main->lacksSameOriginSecurity(), 'Basic test, should have security' );
811 new FauxRequest( [ 'action' => 'query', 'format' => 'xml', 'callback' => 'foo' ] )
813 $this->assertTrue( $main->lacksSameOriginSecurity(), 'JSONp, should lack security' );
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' );
822 $this->mergeMwGlobalArrayValue( 'wgHooks', [
823 'RequestHasSameOriginSecurity' => [ function () {
827 $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
828 $this->assertTrue( $main->lacksSameOriginSecurity(), 'Hook, should lack security' );
832 * Test proper creation of the ApiErrorFormatter
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
843 public function testApiErrorFormatterCreation( array $request, array $expect ) {
844 $context = new RequestContext();
845 $context->setRequest( new FauxRequest( $request ) );
846 $context->setLanguage( 'ru' );
848 $main = new ApiMain( $context );
849 $formatter = $main->getErrorFormatter();
850 $wrappedFormatter = TestingAccessWrapper
::newFromObject( $formatter );
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
);
859 public static function provideApiErrorFormatterCreation() {
861 'Default (BC)' => [ [], [
863 'class' => ApiErrorFormatter_BackCompat
::class,
868 'BC ignores fields' => [ [ 'errorlang' => 'de', 'errorsuselocal' => 1 ], [
870 'class' => ApiErrorFormatter_BackCompat
::class,
875 'Explicit BC' => [ [ 'errorformat' => 'bc' ], [
877 'class' => ApiErrorFormatter_BackCompat
::class,
882 'Basic' => [ [ 'errorformat' => 'wikitext' ], [
884 'class' => ApiErrorFormatter
::class,
886 'format' => 'wikitext',
889 'Follows uselang' => [ [ 'uselang' => 'fr', 'errorformat' => 'plaintext' ], [
891 'class' => ApiErrorFormatter
::class,
893 'format' => 'plaintext',
896 'Explicitly follows uselang' => [
897 [ 'uselang' => 'fr', 'errorlang' => 'uselang', 'errorformat' => 'plaintext' ],
900 'class' => ApiErrorFormatter
::class,
902 'format' => 'plaintext',
906 'uselang=content' => [
907 [ 'uselang' => 'content', 'errorformat' => 'plaintext' ],
910 'class' => ApiErrorFormatter
::class,
912 'format' => 'plaintext',
916 'errorlang=content' => [
917 [ 'errorlang' => 'content', 'errorformat' => 'plaintext' ],
920 'class' => ApiErrorFormatter
::class,
922 'format' => 'plaintext',
926 'Explicit parameters' => [
927 [ 'errorlang' => 'de', 'errorformat' => 'html', 'errorsuselocal' => 1 ],
930 'class' => ApiErrorFormatter
::class,
936 'Explicit parameters override uselang' => [
937 [ 'errorlang' => 'de', 'uselang' => 'fr', 'errorformat' => 'raw' ],
940 'class' => ApiErrorFormatter
::class,
946 'Bogus language doesn\'t explode' => [
947 [ 'errorlang' => '<bogus1>', 'uselang' => '<bogus2>', 'errorformat' => 'none' ],
950 'class' => ApiErrorFormatter
::class,
956 'Bogus format doesn\'t explode' => [ [ 'errorformat' => 'bogus' ], [
958 'class' => ApiErrorFormatter_BackCompat
::class,
967 * @dataProvider provideExceptionErrors
968 * @param Exception $exception
969 * @param array $expectReturn
970 * @param array $expectResult
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( [
978 'ShowHostnames' => true, 'ShowExceptionDetails' => true,
980 $context->getConfig()
983 $main = new ApiMain( $context );
984 $main->addWarning( new RawMessage( 'existing warning' ), 'existing-warning' );
985 $main->addError( new RawMessage( 'existing error' ), 'existing-error' );
987 $ret = TestingAccessWrapper
::newFromObject( $main )->substituteResultWithError( $error );
988 $this->assertSame( $expectReturn, $ret );
990 // PHPUnit sometimes adds some SplObjectStorage garbage to the arrays,
991 // so let's try ->assertEquals().
994 $main->getResult()->getResultData( [], [ 'Strip' => 'all' ] )
998 // Not static so $this can be used
999 public function provideExceptionErrors() {
1000 $reqId = WebRequest
::getRequestId();
1001 $doclink = wfExpandUrl( wfScript( 'api' ) );
1003 $ex = new InvalidArgumentException( 'Random exception' );
1004 $trace = wfMessage( 'api-exception-trace',
1008 MWExceptionHandler
::getRedactedTraceAsString( $ex )
1009 )->inLanguage( 'en' )->useDatabase( false )->text();
1011 $dbex = new DBQueryError(
1012 $this->createMock( \Wikimedia\Rdbms\IDatabase
::class ),
1013 'error', 1234, 'SELECT 1', __METHOD__
);
1014 $dbtrace = wfMessage( 'api-exception-trace',
1018 MWExceptionHandler
::getRedactedTraceAsString( $dbex )
1019 )->inLanguage( 'en' )->useDatabase( false )->text();
1021 // The specific exception doesn't matter, as long as it's namespaced.
1022 $nsex = new MediaWiki\
ShellDisabledError();
1023 $nstrace = wfMessage( 'api-exception-trace',
1027 MWExceptionHandler
::getRedactedTraceAsString( $nsex )
1028 )->inLanguage( 'en' )->useDatabase( false )->text();
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' ) );
1037 $badMsg = $this->getMockBuilder( ApiRawMessage
::class )
1038 ->setConstructorArgs( [ 'An error', 'ignored' ] )
1039 ->setMethods( [ 'getApiCode' ] )
1041 $badMsg->method( 'getApiCode' )->willReturn( "bad\nvalue" );
1042 $apiEx2 = new ApiUsageException( null, StatusValue
::newFatal( $badMsg ) );
1047 [ 'existing-error', 'internal_api_error_InvalidArgumentException' ],
1050 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
1053 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
1055 'code' => 'internal_api_error_InvalidArgumentException',
1056 'text' => "[$reqId] Exception caught: Random exception",
1058 'errorclass' => InvalidArgumentException
::class,
1063 'servedby' => wfHostname(),
1068 [ 'existing-error', 'internal_api_error_DBQueryError' ],
1071 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
1074 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
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.",
1080 'errorclass' => DBQueryError
::class,
1084 'trace' => $dbtrace,
1085 'servedby' => wfHostname(),
1090 [ 'existing-error', 'internal_api_error_MediaWiki\ShellDisabledError' ],
1093 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
1096 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
1098 'code' => 'internal_api_error_MediaWiki\ShellDisabledError',
1099 'text' => "[$reqId] Exception caught: " . $nsex->getMessage(),
1101 'errorclass' => MediaWiki\ShellDisabledError
::class,
1105 'trace' => $nstrace,
1106 'servedby' => wfHostname(),
1111 [ 'existing-error', 'sv-error1', 'sv-error2' ],
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' ],
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' ],
1123 'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
1124 "list at <https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce> " .
1125 "for notice of API deprecations and breaking changes.",
1126 'servedby' => wfHostname(),
1131 [ 'existing-error', '<invalid-code>' ],
1134 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
1137 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
1138 [ 'code' => "bad\nvalue", 'text' => 'An error' ],
1140 'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
1141 "list at <https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce> " .
1142 "for notice of API deprecations and breaking changes.",
1143 'servedby' => wfHostname(),
1149 public function testPrinterParameterValidationError() {
1150 $api = $this->getNonInternalApiMain( [
1151 'action' => 'query', 'meta' => 'siteinfo', 'format' => 'json', 'formatversion' => 'bogus',
1156 $txt = ob_get_clean();
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'] );