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 &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; " .
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 &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; " .
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