ApiMainTest.php 35 KB

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