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