1<?php 2 3namespace MediaWiki\Tests\Revision; 4 5use CommentStoreComment; 6use Content; 7use LogicException; 8use MediaWiki\Content\IContentHandlerFactory; 9use MediaWiki\MediaWikiServices; 10use MediaWiki\Revision\MainSlotRoleHandler; 11use MediaWiki\Revision\MutableRevisionRecord; 12use MediaWiki\Revision\RevisionRecord; 13use MediaWiki\Revision\RevisionRenderer; 14use MediaWiki\Revision\SlotRecord; 15use MediaWiki\Revision\SlotRoleRegistry; 16use MediaWiki\Storage\NameTableStore; 17use MediaWiki\User\UserIdentityValue; 18use MediaWikiIntegrationTestCase; 19use ParserOptions; 20use ParserOutput; 21use PHPUnit\Framework\MockObject\MockObject; 22use Title; 23use Wikimedia\Rdbms\IDatabase; 24use Wikimedia\Rdbms\ILoadBalancer; 25use WikitextContent; 26 27/** 28 * @covers \MediaWiki\Revision\RevisionRenderer 29 */ 30class RevisionRendererTest extends MediaWikiIntegrationTestCase { 31 32 /** 33 * @param int $articleId 34 * @param int $revisionId 35 * @return Title 36 */ 37 private function getMockTitle( $articleId, $revisionId ) { 38 /** @var Title|MockObject $mock */ 39 $mock = $this->getMockBuilder( Title::class ) 40 ->disableOriginalConstructor() 41 ->getMock(); 42 $mock->expects( $this->any() ) 43 ->method( 'getNamespace' ) 44 ->will( $this->returnValue( NS_MAIN ) ); 45 $mock->expects( $this->any() ) 46 ->method( 'getText' ) 47 ->will( $this->returnValue( __CLASS__ ) ); 48 $mock->expects( $this->any() ) 49 ->method( 'getPrefixedText' ) 50 ->will( $this->returnValue( __CLASS__ ) ); 51 $mock->expects( $this->any() ) 52 ->method( 'getDBkey' ) 53 ->will( $this->returnValue( __CLASS__ ) ); 54 $mock->expects( $this->any() ) 55 ->method( 'getArticleID' ) 56 ->will( $this->returnValue( $articleId ) ); 57 $mock->expects( $this->any() ) 58 ->method( 'getLatestRevId' ) 59 ->will( $this->returnValue( $revisionId ) ); 60 $mock->expects( $this->any() ) 61 ->method( 'getContentModel' ) 62 ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT ) ); 63 $mock->expects( $this->any() ) 64 ->method( 'getPageLanguage' ) 65 ->will( $this->returnValue( 66 MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( 'en' ) ) ); 67 $mock->expects( $this->any() ) 68 ->method( 'isContentPage' ) 69 ->will( $this->returnValue( true ) ); 70 $mock->expects( $this->any() ) 71 ->method( 'equals' ) 72 ->willReturnCallback( 73 function ( Title $other ) use ( $mock ) { 74 return $mock->getArticleID() === $other->getArticleID(); 75 } 76 ); 77 $mock->expects( $this->any() ) 78 ->method( 'getRestrictions' ) 79 ->willReturn( [] ); 80 81 return $mock; 82 } 83 84 /** 85 * @param int $maxRev 86 * @param int $linkCount 87 * 88 * @return IDatabase 89 */ 90 private function getMockDatabaseConnection( $maxRev = 100, $linkCount = 0 ) { 91 /** @var IDatabase|MockObject $db */ 92 $db = $this->createMock( IDatabase::class ); 93 $db->method( 'selectField' ) 94 ->willReturnCallback( 95 function ( $table, $fields, $cond ) use ( $maxRev, $linkCount ) { 96 return $this->selectFieldCallback( 97 $table, 98 $fields, 99 $cond, 100 $maxRev, 101 $linkCount 102 ); 103 } 104 ); 105 106 return $db; 107 } 108 109 /** 110 * @return RevisionRenderer 111 */ 112 private function newRevisionRenderer( $maxRev = 100, $useMaster = false ) { 113 $dbIndex = $useMaster ? DB_MASTER : DB_REPLICA; 114 115 $db = $this->getMockDatabaseConnection( $maxRev ); 116 117 /** @var ILoadBalancer|MockObject $lb */ 118 $lb = $this->createMock( ILoadBalancer::class ); 119 $lb->method( 'getConnection' ) 120 ->with( $dbIndex ) 121 ->willReturn( $db ); 122 $lb->method( 'getConnectionRef' ) 123 ->with( $dbIndex ) 124 ->willReturn( $db ); 125 $lb->method( 'getLazyConnectionRef' ) 126 ->with( $dbIndex ) 127 ->willReturn( $db ); 128 129 /** @var NameTableStore|MockObject $slotRoles */ 130 $slotRoles = $this->getMockBuilder( NameTableStore::class ) 131 ->disableOriginalConstructor() 132 ->getMock(); 133 $slotRoles->method( 'getMap' ) 134 ->willReturn( [] ); 135 136 $roleReg = new SlotRoleRegistry( $slotRoles ); 137 $roleReg->defineRole( 'main', function () { 138 return new MainSlotRoleHandler( 139 [], 140 $this->createMock( IContentHandlerFactory::class ) 141 ); 142 } ); 143 $roleReg->defineRoleWithModel( 'aux', CONTENT_MODEL_WIKITEXT ); 144 145 return new RevisionRenderer( $lb, $roleReg ); 146 } 147 148 private function selectFieldCallback( $table, $fields, $cond, $maxRev ) { 149 if ( [ $table, $fields, $cond ] === [ 'revision', 'MAX(rev_id)', [] ] ) { 150 return $maxRev; 151 } 152 153 $this->fail( 'Unexpected call to selectField' ); 154 throw new LogicException( 'Ooops' ); // Can't happen, make analyzer happy 155 } 156 157 public function testGetRenderedRevision_new() { 158 $renderer = $this->newRevisionRenderer( 100 ); 159 $title = $this->getMockTitle( 7, 21 ); 160 161 $rev = new MutableRevisionRecord( $title ); 162 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); 163 $rev->setTimestamp( '20180101000003' ); 164 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) ); 165 166 $text = ""; 167 $text .= "* page:{{PAGENAME}}\n"; 168 $text .= "* rev:{{REVISIONID}}\n"; 169 $text .= "* user:{{REVISIONUSER}}\n"; 170 $text .= "* time:{{REVISIONTIMESTAMP}}\n"; 171 $text .= "* [[Link It]]\n"; 172 173 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) ); 174 175 $options = ParserOptions::newCanonical( 'canonical' ); 176 $rr = $renderer->getRenderedRevision( $rev, $options ); 177 178 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' ); 179 180 $this->assertSame( $rev, $rr->getRevision() ); 181 $this->assertSame( $options, $rr->getOptions() ); 182 183 $html = $rr->getRevisionParserOutput()->getText(); 184 185 $this->assertStringContainsString( 'page:' . __CLASS__, $html ); 186 $this->assertStringContainsString( 'rev:101', $html ); // from speculativeRevIdCallback 187 $this->assertStringContainsString( 'user:Frank', $html ); 188 $this->assertStringContainsString( 'time:20180101000003', $html ); 189 190 $this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() ); 191 } 192 193 public function testGetRenderedRevision_current() { 194 $renderer = $this->newRevisionRenderer( 100 ); 195 $title = $this->getMockTitle( 7, 21 ); 196 197 $rev = new MutableRevisionRecord( $title ); 198 $rev->setId( 21 ); // current! 199 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); 200 $rev->setTimestamp( '20180101000003' ); 201 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) ); 202 203 $text = ""; 204 $text .= "* page:{{PAGENAME}}\n"; 205 $text .= "* rev:{{REVISIONID}}\n"; 206 $text .= "* user:{{REVISIONUSER}}\n"; 207 $text .= "* time:{{REVISIONTIMESTAMP}}\n"; 208 209 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) ); 210 211 $options = ParserOptions::newCanonical( 'canonical' ); 212 $rr = $renderer->getRenderedRevision( $rev, $options ); 213 214 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' ); 215 216 $this->assertSame( $rev, $rr->getRevision() ); 217 $this->assertSame( $options, $rr->getOptions() ); 218 219 $html = $rr->getRevisionParserOutput()->getText(); 220 221 $this->assertStringContainsString( 'page:' . __CLASS__, $html ); 222 $this->assertStringContainsString( 'rev:21', $html ); 223 $this->assertStringContainsString( 'user:Frank', $html ); 224 $this->assertStringContainsString( 'time:20180101000003', $html ); 225 226 $this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() ); 227 } 228 229 public function testGetRenderedRevision_master() { 230 $renderer = $this->newRevisionRenderer( 100, true ); // use master 231 $title = $this->getMockTitle( 7, 21 ); 232 233 $rev = new MutableRevisionRecord( $title ); 234 $rev->setId( 21 ); // current! 235 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); 236 $rev->setTimestamp( '20180101000003' ); 237 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) ); 238 239 $text = ""; 240 $text .= "* page:{{PAGENAME}}\n"; 241 $text .= "* rev:{{REVISIONID}}\n"; 242 $text .= "* user:{{REVISIONUSER}}\n"; 243 $text .= "* time:{{REVISIONTIMESTAMP}}\n"; 244 245 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) ); 246 247 $options = ParserOptions::newCanonical( 'canonical' ); 248 $rr = $renderer->getRenderedRevision( $rev, $options, null, [ 'use-master' => true ] ); 249 250 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' ); 251 252 $html = $rr->getRevisionParserOutput()->getText(); 253 254 $this->assertStringContainsString( 'rev:21', $html ); 255 256 $this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() ); 257 } 258 259 public function testGetRenderedRevision_known() { 260 $renderer = $this->newRevisionRenderer( 100, true ); // use master 261 $title = $this->getMockTitle( 7, 21 ); 262 263 $rev = new MutableRevisionRecord( $title ); 264 $rev->setId( 21 ); // current! 265 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); 266 $rev->setTimestamp( '20180101000003' ); 267 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) ); 268 269 $text = "uncached text"; 270 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) ); 271 272 $output = new ParserOutput( 'cached text' ); 273 274 $options = ParserOptions::newCanonical( 'canonical' ); 275 $rr = $renderer->getRenderedRevision( 276 $rev, 277 $options, 278 null, 279 [ 'known-revision-output' => $output ] 280 ); 281 282 $this->assertSame( $output, $rr->getRevisionParserOutput() ); 283 $this->assertSame( 'cached text', $rr->getRevisionParserOutput()->getText() ); 284 $this->assertSame( 'cached text', $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() ); 285 } 286 287 public function testGetRenderedRevision_old() { 288 $renderer = $this->newRevisionRenderer( 100 ); 289 $title = $this->getMockTitle( 7, 21 ); 290 291 $rev = new MutableRevisionRecord( $title ); 292 $rev->setId( 11 ); // old! 293 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); 294 $rev->setTimestamp( '20180101000003' ); 295 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) ); 296 297 $text = ""; 298 $text .= "* page:{{PAGENAME}}\n"; 299 $text .= "* rev:{{REVISIONID}}\n"; 300 $text .= "* user:{{REVISIONUSER}}\n"; 301 $text .= "* time:{{REVISIONTIMESTAMP}}\n"; 302 303 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) ); 304 305 $options = ParserOptions::newCanonical( 'canonical' ); 306 $rr = $renderer->getRenderedRevision( $rev, $options ); 307 308 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' ); 309 310 $this->assertSame( $rev, $rr->getRevision() ); 311 $this->assertSame( $options, $rr->getOptions() ); 312 313 $html = $rr->getRevisionParserOutput()->getText(); 314 315 $this->assertStringContainsString( 'page:' . __CLASS__, $html ); 316 $this->assertStringContainsString( 'rev:11', $html ); 317 $this->assertStringContainsString( 'user:Frank', $html ); 318 $this->assertStringContainsString( 'time:20180101000003', $html ); 319 320 $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); 321 } 322 323 public function testGetRenderedRevision_suppressed() { 324 $renderer = $this->newRevisionRenderer( 100 ); 325 $title = $this->getMockTitle( 7, 21 ); 326 327 $rev = new MutableRevisionRecord( $title ); 328 $rev->setId( 11 ); // old! 329 $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed! 330 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); 331 $rev->setTimestamp( '20180101000003' ); 332 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) ); 333 334 $text = ""; 335 $text .= "* page:{{PAGENAME}}\n"; 336 $text .= "* rev:{{REVISIONID}}\n"; 337 $text .= "* user:{{REVISIONUSER}}\n"; 338 $text .= "* time:{{REVISIONTIMESTAMP}}\n"; 339 340 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) ); 341 342 $options = ParserOptions::newCanonical( 'canonical' ); 343 $rr = $renderer->getRenderedRevision( $rev, $options ); 344 345 $this->assertNull( $rr, 'getRenderedRevision' ); 346 } 347 348 public function testGetRenderedRevision_privileged() { 349 $renderer = $this->newRevisionRenderer( 100 ); 350 $title = $this->getMockTitle( 7, 21 ); 351 352 $rev = new MutableRevisionRecord( $title ); 353 $rev->setId( 11 ); // old! 354 $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed! 355 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); 356 $rev->setTimestamp( '20180101000003' ); 357 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) ); 358 359 $text = ""; 360 $text .= "* page:{{PAGENAME}}\n"; 361 $text .= "* rev:{{REVISIONID}}\n"; 362 $text .= "* user:{{REVISIONUSER}}\n"; 363 $text .= "* time:{{REVISIONTIMESTAMP}}\n"; 364 365 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) ); 366 367 $options = ParserOptions::newCanonical( 'canonical' ); 368 $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged! 369 $rr = $renderer->getRenderedRevision( $rev, $options, $sysop ); 370 371 $this->assertNotNull( $rr, 'getRenderedRevision' ); 372 $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' ); 373 374 $this->assertSame( $rev, $rr->getRevision() ); 375 $this->assertSame( $options, $rr->getOptions() ); 376 377 $html = $rr->getRevisionParserOutput()->getText(); 378 379 // Suppressed content should be visible for sysops 380 $this->assertStringContainsString( 'page:' . __CLASS__, $html ); 381 $this->assertStringContainsString( 'rev:11', $html ); 382 $this->assertStringContainsString( 'user:Frank', $html ); 383 $this->assertStringContainsString( 'time:20180101000003', $html ); 384 385 $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); 386 } 387 388 public function testGetRenderedRevision_raw() { 389 $renderer = $this->newRevisionRenderer( 100 ); 390 $title = $this->getMockTitle( 7, 21 ); 391 392 $rev = new MutableRevisionRecord( $title ); 393 $rev->setId( 11 ); // old! 394 $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed! 395 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); 396 $rev->setTimestamp( '20180101000003' ); 397 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) ); 398 399 $text = ""; 400 $text .= "* page:{{PAGENAME}}\n"; 401 $text .= "* rev:{{REVISIONID}}\n"; 402 $text .= "* user:{{REVISIONUSER}}\n"; 403 $text .= "* time:{{REVISIONTIMESTAMP}}\n"; 404 405 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) ); 406 407 $options = ParserOptions::newCanonical( 'canonical' ); 408 $rr = $renderer->getRenderedRevision( 409 $rev, 410 $options, 411 null, 412 [ 'audience' => RevisionRecord::RAW ] 413 ); 414 415 $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' ); 416 417 $this->assertSame( $rev, $rr->getRevision() ); 418 $this->assertSame( $options, $rr->getOptions() ); 419 420 $html = $rr->getRevisionParserOutput()->getText(); 421 422 // Suppressed content should be visible in raw mode 423 $this->assertStringContainsString( 'page:' . __CLASS__, $html ); 424 $this->assertStringContainsString( 'rev:11', $html ); 425 $this->assertStringContainsString( 'user:Frank', $html ); 426 $this->assertStringContainsString( 'time:20180101000003', $html ); 427 428 $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); 429 } 430 431 public function testGetRenderedRevision_multi() { 432 $renderer = $this->newRevisionRenderer(); 433 $title = $this->getMockTitle( 7, 21 ); 434 435 $rev = new MutableRevisionRecord( $title ); 436 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); 437 $rev->setTimestamp( '20180101000003' ); 438 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) ); 439 440 $rev->setContent( SlotRecord::MAIN, new WikitextContent( '[[Kittens]]' ) ); 441 $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) ); 442 443 $rr = $renderer->getRenderedRevision( $rev ); 444 445 $combinedOutput = $rr->getRevisionParserOutput(); 446 $mainOutput = $rr->getSlotParserOutput( SlotRecord::MAIN ); 447 $auxOutput = $rr->getSlotParserOutput( 'aux' ); 448 449 $combinedHtml = $combinedOutput->getText(); 450 $mainHtml = $mainOutput->getText(); 451 $auxHtml = $auxOutput->getText(); 452 453 $this->assertStringContainsString( 'Kittens', $mainHtml ); 454 $this->assertStringContainsString( 'Goats', $auxHtml ); 455 $this->assertStringNotContainsString( 'Goats', $mainHtml ); 456 $this->assertStringNotContainsString( 'Kittens', $auxHtml ); 457 $this->assertStringContainsString( 'Kittens', $combinedHtml ); 458 $this->assertStringContainsString( 'Goats', $combinedHtml ); 459 $this->assertStringContainsString( '>aux<', $combinedHtml, 'slot header' ); 460 $this->assertStringNotContainsString( 461 '<mw:slotheader', 462 $combinedHtml, 463 'slot header placeholder' 464 ); 465 466 // make sure output wrapping works right 467 $this->assertStringContainsString( 'class="mw-parser-output"', $mainHtml ); 468 $this->assertStringContainsString( 'class="mw-parser-output"', $auxHtml ); 469 $this->assertStringContainsString( 'class="mw-parser-output"', $combinedHtml ); 470 471 // there should be only one wrapper div 472 $this->assertSame( 1, preg_match_all( '#class="mw-parser-output"#', $combinedHtml ) ); 473 $this->assertStringNotContainsString( 'class="mw-parser-output"', $combinedOutput->getRawText() ); 474 475 $combinedLinks = $combinedOutput->getLinks(); 476 $mainLinks = $mainOutput->getLinks(); 477 $auxLinks = $auxOutput->getLinks(); 478 $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' ); 479 $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' ); 480 $this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' ); 481 $this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' ); 482 } 483 484 public function testGetRenderedRevision_noHtml() { 485 /** @var MockObject|Content $mockContent */ 486 $mockContent = $this->getMockBuilder( WikitextContent::class ) 487 ->setMethods( [ 'getParserOutput' ] ) 488 ->setConstructorArgs( [ 'Whatever' ] ) 489 ->getMock(); 490 $mockContent->method( 'getParserOutput' ) 491 ->willReturnCallback( function ( Title $title, $revId = null, 492 ParserOptions $options = null, $generateHtml = true 493 ) { 494 if ( !$generateHtml ) { 495 return new ParserOutput( null ); 496 } else { 497 $this->fail( 'Should not be called with $generateHtml == true' ); 498 return null; // never happens, make analyzer happy 499 } 500 } ); 501 502 $renderer = $this->newRevisionRenderer(); 503 $title = $this->getMockTitle( 7, 21 ); 504 505 $rev = new MutableRevisionRecord( $title ); 506 $rev->setContent( SlotRecord::MAIN, $mockContent ); 507 $rev->setContent( 'aux', $mockContent ); 508 509 // NOTE: we are testing the private combineSlotOutput() callback here. 510 $rr = $renderer->getRenderedRevision( $rev ); 511 512 $output = $rr->getSlotParserOutput( SlotRecord::MAIN, [ 'generate-html' => false ] ); 513 $this->assertFalse( $output->hasText(), 'hasText' ); 514 515 $output = $rr->getRevisionParserOutput( [ 'generate-html' => false ] ); 516 $this->assertFalse( $output->hasText(), 'hasText' ); 517 } 518 519} 520