1<?php 2 3namespace MediaWiki\Tests\Rest\Handler; 4 5use ApiUsageException; 6use HashConfig; 7use MediaWiki\Content\IContentHandlerFactory; 8use MediaWiki\Rest\Handler\CreationHandler; 9use MediaWiki\Rest\HttpException; 10use MediaWiki\Rest\LocalizedHttpException; 11use MediaWiki\Rest\RequestData; 12use MediaWiki\Revision\RevisionLookup; 13use MediaWiki\Storage\MutableRevisionRecord; 14use MediaWiki\Storage\SlotRecord; 15use MediaWikiIntegrationTestCase; 16use MediaWikiTitleCodec; 17use MockTitleTrait; 18use PHPUnit\Framework\MockObject\MockObject; 19use Status; 20use Wikimedia\Message\MessageValue; 21use Wikimedia\Message\ParamType; 22use Wikimedia\Message\ScalarParam; 23use WikitextContent; 24 25/** 26 * @covers \MediaWiki\Rest\Handler\CreationHandler 27 */ 28class CreationHandlerTest extends MediaWikiIntegrationTestCase { 29 30 use ActionModuleBasedHandlerTestTrait; 31 use MockTitleTrait; 32 33 private function newHandler( $resultData, $throwException = null, $csrfSafe = false ) { 34 $config = new HashConfig( [ 35 'RightsUrl' => 'https://creativecommons.org/licenses/by-sa/4.0/', 36 'RightsText' => 'CC-BY-SA 4.0' 37 ] ); 38 39 /** @var IContentHandlerFactory|MockObject $contentHandlerFactory */ 40 $contentHandlerFactory = 41 $this->createNoOpMock( IContentHandlerFactory::class, [ 'isDefinedModel' ] ); 42 43 $contentHandlerFactory 44 ->method( 'isDefinedModel' ) 45 ->willReturnMap( [ 46 [ CONTENT_MODEL_WIKITEXT, true ], 47 [ CONTENT_MODEL_TEXT, true ], 48 ] ); 49 50 /** @var MockObject|MediaWikiTitleCodec $titleCodec */ 51 $titleCodec = $this->getMockBuilder( MediaWikiTitleCodec::class ) 52 ->disableOriginalConstructor() 53 ->onlyMethods( [ 'formatTitle', 'splitTitleString' ] ) 54 ->getMock(); 55 56 $titleCodec->method( 'formatTitle' ) 57 ->willReturnCallback( static function ( $namespace, $text ) { 58 return "ns:$namespace:" . ucfirst( $text ); 59 } ); 60 $titleCodec->method( 'splitTitleString' ) 61 ->willReturnCallback( static function ( $text ) { 62 return [ 63 'interwiki' => '', 64 'fragment' => '', 65 'namespace' => 0, 66 'dbkey' => str_replace( ' ', '_', $text ) 67 ]; 68 } ); 69 70 /** @var RevisionLookup|MockObject $revisionLookup */ 71 $revisionLookup = $this->createNoOpMock( RevisionLookup::class, [ 'getRevisionById' ] ); 72 $revisionLookup->method( 'getRevisionById' ) 73 ->willReturnCallback( function ( $id ) { 74 $title = $this->makeMockTitle( __CLASS__ ); 75 $rev = new MutableRevisionRecord( $title ); 76 $rev->setId( $id ); 77 $rev->setContent( SlotRecord::MAIN, new WikitextContent( "Content of revision $id" ) ); 78 return $rev; 79 } ); 80 81 $handler = new CreationHandler( 82 $config, 83 $contentHandlerFactory, 84 $titleCodec, 85 $titleCodec, 86 $revisionLookup 87 ); 88 89 $apiMain = $this->getApiMain( $csrfSafe ); 90 $dummyModule = $this->getDummyApiModule( $apiMain, 'edit', $resultData, $throwException ); 91 92 $handler->setApiMain( $apiMain ); 93 $handler->overrideActionModule( 94 'edit', 95 'action', 96 $dummyModule 97 ); 98 99 return $handler; 100 } 101 102 public function provideExecute() { 103 // NOTE: Prefix hard coded in a fake for Router::getRouteUrl() in HandlerTestTrait 104 $baseUrl = 'https://wiki.example.com/rest/v1/page/'; 105 106 yield "create with token" => [ 107 [ // Request data received by CreationHandler 108 'method' => 'POST', 109 'headers' => [ 110 'Content-Type' => 'application/json', 111 ], 112 'bodyContents' => json_encode( [ 113 'token' => 'TOKEN', 114 'title' => 'Foo', 115 'source' => 'Lorem Ipsum', 116 'comment' => 'Testing' 117 ] ), 118 ], 119 [ // Fake request expected to be passed into ApiEditPage 120 'title' => 'Foo', 121 'text' => 'Lorem Ipsum', 122 'summary' => 'Testing', 123 'createonly' => '1', 124 ], 125 [ // Mock response returned by ApiEditPage 126 "edit" => [ 127 "new" => true, 128 "result" => "Success", 129 "pageid" => 94542, 130 "title" => "Foo", 131 "contentmodel" => "wikitext", 132 "oldrevid" => 0, 133 "newrevid" => 371707, 134 "newtimestamp" => "2018-12-18T16:59:42Z", 135 ] 136 ], 137 [ // Response expected to be generated by CreationHandler 138 'id' => 94542, 139 'title' => 'ns:0:Foo', 140 'key' => 'ns:0:Foo', 141 'content_model' => 'wikitext', 142 'latest' => [ 143 'id' => 371707, 144 'timestamp' => "2018-12-18T16:59:42Z" 145 ], 146 'license' => [ 147 'url' => 'https://creativecommons.org/licenses/by-sa/4.0/', 148 'title' => 'CC-BY-SA 4.0' 149 ], 150 'source' => 'Content of revision 371707' 151 ], 152 $baseUrl . 'Foo', 153 false 154 ]; 155 156 yield "create with model" => [ 157 [ // Request data received by CreationHandler 158 'method' => 'POST', 159 'headers' => [ 160 'Content-Type' => 'application/json', 161 ], 162 'bodyContents' => json_encode( [ 163 'title' => 'Talk:Foo', 164 'source' => 'Lorem Ipsum', 165 'comment' => 'Testing', 166 'content_model' => CONTENT_MODEL_TEXT, 167 ] ), 168 ], 169 [ // Fake request expected to be passed into ApiEditPage 170 'title' => 'Talk:Foo', 171 'text' => 'Lorem Ipsum', 172 'summary' => 'Testing', 173 'contentmodel' => CONTENT_MODEL_TEXT, 174 'createonly' => '1', 175 'token' => '+\\', 176 ], 177 [ // Mock response returned by ApiEditPage 178 "edit" => [ 179 "new" => true, 180 "result" => "Success", 181 "pageid" => 94542, 182 "title" => "Talk:Foo", 183 "contentmodel" => CONTENT_MODEL_TEXT, 184 "oldrevid" => 0, 185 "newrevid" => 371707, 186 "newtimestamp" => "2018-12-18T16:59:42Z", 187 ] 188 ], 189 [ // Response expected to be generated by CreationHandler 190 'id' => 94542, 191 'title' => 'ns:0:Talk:Foo', // our mock TitleCodec doesn't parse namespaces 192 'key' => 'ns:0:Talk:Foo', 193 'content_model' => CONTENT_MODEL_TEXT, 194 'latest' => [ 195 'id' => 371707, 196 'timestamp' => "2018-12-18T16:59:42Z" 197 ], 198 'license' => [ 199 'url' => 'https://creativecommons.org/licenses/by-sa/4.0/', 200 'title' => 'CC-BY-SA 4.0' 201 ], 202 'source' => 'Content of revision 371707' 203 ], 204 $baseUrl . 'Talk:Foo', 205 true 206 ]; 207 208 yield "create without token" => [ 209 [ // Request data received by CreationHandler 210 'method' => 'POST', 211 'headers' => [ 212 'Content-Type' => 'application/json', 213 ], 214 'bodyContents' => json_encode( [ 215 'title' => 'foo/bar', 216 'source' => 'Lorem Ipsum', 217 'comment' => 'Testing', 218 'content_model' => CONTENT_MODEL_WIKITEXT, 219 ] ), 220 ], 221 [ // Fake request expected to be passed into ApiEditPage 222 'title' => 'foo/bar', 223 'text' => 'Lorem Ipsum', 224 'summary' => 'Testing', 225 'contentmodel' => 'wikitext', 226 'createonly' => '1', 227 'token' => '+\\', // use known-good token for current user (anon) 228 ], 229 [ // Mock response returned by ApiEditPage 230 "edit" => [ 231 "new" => true, 232 "result" => "Success", 233 "pageid" => 94542, 234 "title" => "Foo/bar", 235 "contentmodel" => "wikitext", 236 "oldrevid" => 0, 237 "newrevid" => 371707, 238 "newtimestamp" => "2018-12-18T16:59:42Z", 239 ] 240 ], 241 [ // Response expected to be generated by CreationHandler 242 'id' => 94542, 243 'title' => 'ns:0:Foo/bar', 244 'key' => 'ns:0:Foo/bar', 245 'content_model' => 'wikitext', 246 'latest' => [ 247 'id' => 371707, 248 'timestamp' => "2018-12-18T16:59:42Z" 249 ], 250 'license' => [ 251 'url' => 'https://creativecommons.org/licenses/by-sa/4.0/', 252 'title' => 'CC-BY-SA 4.0' 253 ], 254 'source' => 'Content of revision 371707' 255 ], 256 $baseUrl . 'Foo%2Fbar', 257 true 258 ]; 259 260 yield "create with space" => [ 261 [ // Request data received by CreationHandler 262 'method' => 'POST', 263 'headers' => [ 264 'Content-Type' => 'application/json', 265 ], 266 'bodyContents' => json_encode( [ 267 'title' => 'foo (ba+r)', 268 'source' => 'Lorem Ipsum', 269 'comment' => 'Testing' 270 ] ), 271 ], 272 [ // Fake request expected to be passed into ApiEditPage 273 'title' => 'foo (ba+r)', 274 'text' => 'Lorem Ipsum', 275 'summary' => 'Testing', 276 'createonly' => '1', 277 'token' => '+\\', // use known-good token for current user (anon) 278 ], 279 [ // Mock response returned by ApiEditPage 280 "edit" => [ 281 "new" => true, 282 "result" => "Success", 283 "pageid" => 94542, 284 "title" => "Foo (ba+r)", 285 "contentmodel" => "wikitext", 286 "oldrevid" => 0, 287 "newrevid" => 371707, 288 "newtimestamp" => "2018-12-18T16:59:42Z", 289 ] 290 ], 291 [ // Response expected to be generated by CreationHandler 292 'id' => 94542, 293 'title' => 'ns:0:Foo (ba+r)', 294 'key' => 'ns:0:Foo_(ba+r)', 295 'content_model' => 'wikitext', 296 'latest' => [ 297 'id' => 371707, 298 'timestamp' => "2018-12-18T16:59:42Z" 299 ], 300 'license' => [ 301 'url' => 'https://creativecommons.org/licenses/by-sa/4.0/', 302 'title' => 'CC-BY-SA 4.0' 303 ], 304 'source' => 'Content of revision 371707' 305 ], 306 $baseUrl . 'Foo_(ba%2Br)', 307 true 308 ]; 309 } 310 311 /** 312 * @dataProvider provideExecute 313 */ 314 public function testExecute( 315 $requestData, 316 $expectedActionParams, 317 $actionResult, 318 $expectedResponse, 319 $expectedRedirect, 320 $csrfSafe 321 ) { 322 $request = new RequestData( $requestData ); 323 324 $handler = $this->newHandler( $actionResult, null, $csrfSafe ); 325 326 $response = $this->executeHandler( $handler, $request ); 327 328 $this->assertSame( 201, $response->getStatusCode() ); 329 $this->assertSame( 330 $expectedRedirect, 331 $response->getHeaderLine( 'Location' ) 332 ); 333 $this->assertSame( 'application/json', $response->getHeaderLine( 'Content-Type' ) ); 334 335 $responseData = json_decode( $response->getBody(), true ); 336 $this->assertIsArray( $responseData, 'Body must be a JSON array' ); 337 338 // Check parameters passed to ApiEditPage by CreationHandler based on $requestData 339 foreach ( $expectedActionParams as $key => $value ) { 340 $this->assertSame( 341 $value, 342 $handler->getApiMain()->getVal( $key ), 343 "ApiEditPage param: $key" 344 ); 345 } 346 347 // Check response that CreationHandler created after receiving $actionResult from ApiEditPage 348 foreach ( $expectedResponse as $key => $value ) { 349 $this->assertArrayHasKey( $key, $responseData ); 350 $this->assertSame( 351 $value, 352 $responseData[ $key ], 353 "CreationHandler response field: $key" 354 ); 355 } 356 } 357 358 public function provideBodyValidation() { 359 yield "missing source field" => [ 360 [ // Request data received by CreationHandler 361 'method' => 'POST', 362 'headers' => [ 363 'Content-Type' => 'application/json', 364 ], 365 'bodyContents' => json_encode( [ 366 'token' => 'TOKEN', 367 'title' => 'Foo', 368 'comment' => 'Testing', 369 'content_model' => CONTENT_MODEL_WIKITEXT, 370 ] ), 371 ], 372 new MessageValue( 'rest-missing-body-field', [ 'source' ] ), 373 ]; 374 yield "missing comment field" => [ 375 [ // Request data received by CreationHandler 376 'method' => 'POST', 377 'headers' => [ 378 'Content-Type' => 'application/json', 379 ], 380 'bodyContents' => json_encode( [ 381 'token' => 'TOKEN', 382 'title' => 'Foo', 383 'source' => 'Lorem Ipsum', 384 'content_model' => CONTENT_MODEL_WIKITEXT, 385 ] ), 386 ], 387 new MessageValue( 'rest-missing-body-field', [ 'comment' ] ), 388 ]; 389 yield "missing title field" => [ 390 [ // Request data received by CreationHandler 391 'method' => 'POST', 392 'headers' => [ 393 'Content-Type' => 'application/json', 394 ], 395 'bodyContents' => json_encode( [ 396 'token' => 'TOKEN', 397 'comment' => 'Testing', 398 'source' => 'Lorem Ipsum', 399 'content_model' => CONTENT_MODEL_WIKITEXT, 400 ] ), 401 ], 402 new MessageValue( 'rest-missing-body-field', [ 'title' ] ), 403 ]; 404 } 405 406 /** 407 * @dataProvider provideBodyValidation 408 */ 409 public function testBodyValidation( array $requestData, MessageValue $expectedMessage ) { 410 $request = new RequestData( $requestData ); 411 412 $handler = $this->newHandler( [] ); 413 414 $exception = $this->executeHandlerAndGetHttpException( $handler, $request ); 415 416 $this->assertSame( 400, $exception->getCode(), 'HTTP status' ); 417 $this->assertInstanceOf( LocalizedHttpException::class, $exception ); 418 419 /** @var LocalizedHttpException $exception */ 420 $this->assertEquals( $expectedMessage, $exception->getMessageValue() ); 421 } 422 423 public function provideHeaderValidation() { 424 yield "bad content type" => [ 425 [ // Request data received by CreationHandler 426 'method' => 'POST', 427 'headers' => [ 428 'Content-Type' => 'text/plain', 429 ], 430 'bodyContents' => json_encode( [ 431 'title' => 'Foo', 432 'source' => 'Lorem Ipsum', 433 'comment' => 'Testing', 434 'content_model' => CONTENT_MODEL_WIKITEXT, 435 ] ), 436 ], 437 415 438 ]; 439 } 440 441 public function testBodyValidation_extraneousToken() { 442 $requestData = [ 443 'method' => 'POST', 444 'pathParams' => [ 'title' => 'Foo' ], 445 'headers' => [ 446 'Content-Type' => 'application/json', 447 ], 448 'bodyContents' => json_encode( [ 449 'title' => 'Foo', 450 'token' => 'TOKEN', 451 'comment' => 'Testing', 452 'source' => 'Lorem Ipsum', 453 'content_model' => 'wikitext' 454 ] ), 455 ]; 456 457 $request = new RequestData( $requestData ); 458 459 $handler = $this->newHandler( [], null, true ); 460 461 $exception = $this->executeHandlerAndGetHttpException( $handler, $request ); 462 463 $this->assertSame( 400, $exception->getCode(), 'HTTP status' ); 464 $this->assertInstanceOf( LocalizedHttpException::class, $exception ); 465 466 $expectedMessage = new MessageValue( 'rest-extraneous-csrf-token' ); 467 $this->assertEquals( $expectedMessage, $exception->getMessageValue() ); 468 } 469 470 /** 471 * @dataProvider provideHeaderValidation 472 */ 473 public function testHeaderValidation( array $requestData, $expectedStatus ) { 474 $request = new RequestData( $requestData ); 475 476 $handler = $this->newHandler( [] ); 477 478 $exception = $this->executeHandlerAndGetHttpException( $handler, $request ); 479 480 $this->assertSame( $expectedStatus, $exception->getCode(), 'HTTP status' ); 481 } 482 483 public function provideErrorMapping() { 484 yield "missingtitle" => [ 485 new ApiUsageException( null, Status::newFatal( 'apierror-missingtitle' ) ), 486 new LocalizedHttpException( new MessageValue( 'apierror-missingtitle' ), 404 ), 487 ]; 488 yield "protectedpage" => [ 489 new ApiUsageException( null, Status::newFatal( 'apierror-protectedpage' ) ), 490 new LocalizedHttpException( new MessageValue( 'apierror-protectedpage' ), 403 ), 491 ]; 492 yield "articleexists" => [ 493 new ApiUsageException( null, Status::newFatal( 'apierror-articleexists' ) ), 494 new LocalizedHttpException( new MessageValue( 'apierror-articleexists' ), 409 ), 495 ]; 496 yield "editconflict" => [ 497 new ApiUsageException( null, Status::newFatal( 'apierror-editconflict' ) ), 498 new LocalizedHttpException( new MessageValue( 'apierror-editconflict' ), 409 ), 499 ]; 500 yield "ratelimited" => [ 501 new ApiUsageException( null, Status::newFatal( 'apierror-ratelimited' ) ), 502 new LocalizedHttpException( new MessageValue( 'apierror-ratelimited' ), 429 ), 503 ]; 504 yield "badtoken" => [ 505 new ApiUsageException( 506 null, 507 Status::newFatal( 'apierror-badtoken', [ 'plaintext' => 'BAD' ] ) 508 ), 509 new LocalizedHttpException( 510 new MessageValue( 511 'apierror-badtoken', 512 [ new ScalarParam( ParamType::PLAINTEXT, 'BAD' ) ] 513 ), 403 514 ), 515 ]; 516 517 // Unmapped errors should be passed through with a status 400. 518 yield "no-direct-editing" => [ 519 new ApiUsageException( null, Status::newFatal( 'apierror-no-direct-editing' ) ), 520 new LocalizedHttpException( new MessageValue( 'apierror-no-direct-editing' ), 400 ), 521 ]; 522 yield "badformat" => [ 523 new ApiUsageException( null, Status::newFatal( 'apierror-badformat' ) ), 524 new LocalizedHttpException( new MessageValue( 'apierror-badformat' ), 400 ), 525 ]; 526 yield "emptypage" => [ 527 new ApiUsageException( null, Status::newFatal( 'apierror-emptypage' ) ), 528 new LocalizedHttpException( new MessageValue( 'apierror-emptypage' ), 400 ), 529 ]; 530 } 531 532 /** 533 * @dataProvider provideErrorMapping 534 */ 535 public function testErrorMapping( 536 ApiUsageException $apiUsageException, 537 HttpException $expectedHttpException 538 ) { 539 $requestData = [ // Request data received by CreationHandler 540 'method' => 'POST', 541 'headers' => [ 542 'Content-Type' => 'application/json', 543 ], 544 'bodyContents' => json_encode( [ 545 'title' => 'Foo', 546 'source' => 'Lorem Ipsum', 547 'comment' => 'Testing', 548 'content_model' => CONTENT_MODEL_WIKITEXT, 549 ] ), 550 ]; 551 $request = new RequestData( $requestData ); 552 553 $handler = $this->newHandler( [], $apiUsageException ); 554 555 $exception = $this->executeHandlerAndGetHttpException( $handler, $request ); 556 557 $this->assertSame( $expectedHttpException->getMessage(), $exception->getMessage() ); 558 $this->assertSame( $expectedHttpException->getCode(), $exception->getCode(), 'HTTP status' ); 559 560 $errorData = $exception->getErrorData(); 561 if ( $expectedHttpException->getErrorData() ) { 562 foreach ( $expectedHttpException->getErrorData() as $key => $value ) { 563 $this->assertSame( $value, $errorData[$key], 'Error data key $key' ); 564 } 565 } 566 567 if ( $expectedHttpException instanceof LocalizedHttpException ) { 568 /** @var LocalizedHttpException $exception */ 569 $this->assertEquals( 570 $expectedHttpException->getMessageValue(), 571 $exception->getMessageValue() 572 ); 573 } 574 } 575 576} 577