1<?php 2 3use MediaWiki\Languages\LanguageConverterFactory; 4use MediaWiki\MediaWikiServices; 5use MediaWiki\Page\PageIdentity; 6use MediaWiki\Permissions\Authority; 7use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait; 8use PHPUnit\Framework\MockObject\MockObject; 9use Wikimedia\DependencyStore\KeyValueDependencyStore; 10use Wikimedia\TestingAccessWrapper; 11 12/** 13 * @author Matthew Flaschen 14 * 15 * @group Database 16 * @group Output 17 */ 18class OutputPageTest extends MediaWikiIntegrationTestCase { 19 use MockAuthorityTrait; 20 21 private const SCREEN_MEDIA_QUERY = 'screen and (min-width: 982px)'; 22 private const SCREEN_ONLY_MEDIA_QUERY = 'only screen and (min-width: 982px)'; 23 24 // phpcs:disable Generic.Files.LineLength 25 private const RSS_RC_LINK = '<link rel="alternate" type="application/rss+xml" title=" RSS feed" href="/w/index.php?title=Special:RecentChanges&feed=rss"/>'; 26 private const ATOM_RC_LINK = '<link rel="alternate" type="application/atom+xml" title=" Atom feed" href="/w/index.php?title=Special:RecentChanges&feed=atom"/>'; 27 28 private const RSS_TEST_LINK = '<link rel="alternate" type="application/rss+xml" title=""Test" RSS feed" href="fake-link"/>'; 29 private const ATOM_TEST_LINK = '<link rel="alternate" type="application/atom+xml" title=""Test" Atom feed" href="fake-link"/>'; 30 // phpcs:enable 31 32 // Ensure that we don't affect the global ResourceLoader state. 33 protected function setUp() : void { 34 parent::setUp(); 35 ResourceLoader::clearCache(); 36 } 37 38 protected function tearDown() : void { 39 ResourceLoader::clearCache(); 40 parent::tearDown(); 41 } 42 43 /** 44 * @dataProvider provideRedirect 45 * 46 * @covers OutputPage::__construct 47 * @covers OutputPage::redirect 48 * @covers OutputPage::getRedirect 49 */ 50 public function testRedirect( $url, $code = null ) { 51 $op = $this->newInstance(); 52 if ( isset( $code ) ) { 53 $op->redirect( $url, $code ); 54 } else { 55 $op->redirect( $url ); 56 } 57 $expectedUrl = str_replace( "\n", '', $url ); 58 $this->assertSame( $expectedUrl, $op->getRedirect() ); 59 $this->assertSame( $expectedUrl, $op->mRedirect ); 60 $this->assertSame( $code ?? '302', $op->mRedirectCode ); 61 } 62 63 public function provideRedirect() { 64 return [ 65 [ 'http://example.com' ], 66 [ 'http://example.com', '400' ], 67 [ 'http://example.com', 'squirrels!!!' ], 68 [ "a\nb" ], 69 ]; 70 } 71 72 private function setupFeedLinks( $feed, $types ) : OutputPage { 73 $outputPage = $this->newInstance( [ 74 'AdvertisedFeedTypes' => $types, 75 'Feed' => $feed, 76 'OverrideSiteFeed' => false, 77 'Script' => '/w', 78 'Sitename' => false, 79 ] ); 80 $outputPage->setTitle( Title::makeTitle( NS_MAIN, 'Test' ) ); 81 $this->setMwGlobals( [ 82 'wgScript' => '/w/index.php', 83 ] ); 84 return $outputPage; 85 } 86 87 private function assertFeedLinks( OutputPage $outputPage, $message, $present, $non_present ) { 88 $links = $outputPage->getHeadLinksArray(); 89 foreach ( $present as $link ) { 90 $this->assertContains( $link, $links, $message ); 91 } 92 foreach ( $non_present as $link ) { 93 $this->assertNotContains( $link, $links, $message ); 94 } 95 } 96 97 private function assertFeedUILinks( OutputPage $outputPage, $ui_links ) { 98 if ( $ui_links ) { 99 $this->assertTrue( $outputPage->isSyndicated(), 'Syndication should be offered' ); 100 $this->assertGreaterThan( 0, count( $outputPage->getSyndicationLinks() ), 101 'Some syndication links should be there' ); 102 } else { 103 $this->assertFalse( $outputPage->isSyndicated(), 'No syndication should be offered' ); 104 $this->assertSame( [], $outputPage->getSyndicationLinks(), 105 'No syndication links should be there' ); 106 } 107 } 108 109 public static function provideFeedLinkData() { 110 return [ 111 [ 112 true, [ 'rss' ], 'Only RSS RC link should be offerred', 113 [ self::RSS_RC_LINK ], [ self::ATOM_RC_LINK ] 114 ], 115 [ 116 true, [ 'atom' ], 'Only Atom RC link should be offerred', 117 [ self::ATOM_RC_LINK ], [ self::RSS_RC_LINK ] 118 ], 119 [ 120 true, [], 'No RC feed formats should be offerred', 121 [], [ self::ATOM_RC_LINK, self::RSS_RC_LINK ] 122 ], 123 [ 124 false, [ 'atom' ], 'No RC feeds should be offerred', 125 [], [ self::ATOM_RC_LINK, self::RSS_RC_LINK ] 126 ], 127 ]; 128 } 129 130 /** 131 * @covers OutputPage::setCopyrightUrl 132 * @covers OutputPage::getHeadLinksArray 133 */ 134 public function testSetCopyrightUrl() { 135 $op = $this->newInstance(); 136 $op->setCopyrightUrl( 'http://example.com' ); 137 138 $this->assertSame( 139 Html::element( 'link', [ 'rel' => 'license', 'href' => 'http://example.com' ] ), 140 $op->getHeadLinksArray()['copyright'] 141 ); 142 } 143 144 /** 145 * @dataProvider provideFeedLinkData 146 * @covers OutputPage::getHeadLinksArray 147 */ 148 public function testRecentChangesFeed( $feed, $advertised_feed_types, 149 $message, $present, $non_present ) { 150 $outputPage = $this->setupFeedLinks( $feed, $advertised_feed_types ); 151 $this->assertFeedLinks( $outputPage, $message, $present, $non_present ); 152 } 153 154 public static function provideAdditionalFeedData() { 155 return [ 156 [ 157 true, [ 'atom' ], 'Additional Atom feed should be offered', 158 'atom', 159 [ self::ATOM_TEST_LINK, self::ATOM_RC_LINK ], 160 [ self::RSS_TEST_LINK, self::RSS_RC_LINK ], 161 true, 162 ], 163 [ 164 true, [ 'rss' ], 'Additional RSS feed should be offered', 165 'rss', 166 [ self::RSS_TEST_LINK, self::RSS_RC_LINK ], 167 [ self::ATOM_TEST_LINK, self::ATOM_RC_LINK ], 168 true, 169 ], 170 [ 171 true, [ 'rss' ], 'Additional Atom feed should NOT be offered with RSS enabled', 172 'atom', 173 [ self::RSS_RC_LINK ], 174 [ self::RSS_TEST_LINK, self::ATOM_TEST_LINK, self::ATOM_RC_LINK ], 175 false, 176 ], 177 [ 178 false, [ 'atom' ], 'Additional Atom feed should NOT be offered, all feeds disabled', 179 'atom', 180 [], 181 [ 182 self::RSS_TEST_LINK, self::ATOM_TEST_LINK, 183 self::ATOM_RC_LINK, self::ATOM_RC_LINK, 184 ], 185 false, 186 ], 187 ]; 188 } 189 190 /** 191 * @dataProvider provideAdditionalFeedData 192 * @covers OutputPage::getHeadLinksArray 193 * @covers OutputPage::addFeedLink 194 * @covers OutputPage::getSyndicationLinks 195 * @covers OutputPage::isSyndicated 196 */ 197 public function testAdditionalFeeds( $feed, $advertised_feed_types, $message, 198 $additional_feed_type, $present, $non_present, $any_ui_links ) { 199 $outputPage = $this->setupFeedLinks( $feed, $advertised_feed_types ); 200 $outputPage->addFeedLink( $additional_feed_type, 'fake-link' ); 201 $this->assertFeedLinks( $outputPage, $message, $present, $non_present ); 202 $this->assertFeedUILinks( $outputPage, $any_ui_links ); 203 } 204 205 // @todo How to test setStatusCode? 206 207 /** 208 * @covers OutputPage::addMeta 209 * @covers OutputPage::getMetaTags 210 * @covers OutputPage::getHeadLinksArray 211 */ 212 public function testMetaTags() { 213 $op = $this->newInstance(); 214 $op->addMeta( 'http:expires', '0' ); 215 $op->addMeta( 'keywords', 'first' ); 216 $op->addMeta( 'keywords', 'second' ); 217 $op->addMeta( 'og:title', 'Ta-duh' ); 218 219 $expected = [ 220 [ 'http:expires', '0' ], 221 [ 'keywords', 'first' ], 222 [ 'keywords', 'second' ], 223 [ 'og:title', 'Ta-duh' ], 224 ]; 225 $this->assertSame( $expected, $op->getMetaTags() ); 226 227 $links = $op->getHeadLinksArray(); 228 $this->assertContains( '<meta http-equiv="expires" content="0"/>', $links ); 229 $this->assertContains( '<meta name="keywords" content="first"/>', $links ); 230 $this->assertContains( '<meta name="keywords" content="second"/>', $links ); 231 $this->assertContains( '<meta property="og:title" content="Ta-duh"/>', $links ); 232 $this->assertArrayNotHasKey( 'meta-robots', $links ); 233 } 234 235 /** 236 * @covers OutputPage::addLink 237 * @covers OutputPage::getLinkTags 238 * @covers OutputPage::getHeadLinksArray 239 */ 240 public function testAddLink() { 241 $op = $this->newInstance(); 242 243 $links = [ 244 [], 245 [ 'rel' => 'foo', 'href' => 'http://example.com' ], 246 ]; 247 248 foreach ( $links as $link ) { 249 $op->addLink( $link ); 250 } 251 252 $this->assertSame( $links, $op->getLinkTags() ); 253 254 $result = $op->getHeadLinksArray(); 255 256 foreach ( $links as $link ) { 257 $this->assertContains( Html::element( 'link', $link ), $result ); 258 } 259 } 260 261 /** 262 * @covers OutputPage::setCanonicalUrl 263 * @covers OutputPage::getCanonicalUrl 264 * @covers OutputPage::getHeadLinksArray 265 */ 266 public function testSetCanonicalUrl() { 267 $op = $this->newInstance(); 268 $op->setCanonicalUrl( 'http://example.comm' ); 269 $op->setCanonicalUrl( 'http://example.com' ); 270 271 $this->assertSame( 'http://example.com', $op->getCanonicalUrl() ); 272 273 $headLinks = $op->getHeadLinksArray(); 274 275 $this->assertContains( Html::element( 'link', [ 276 'rel' => 'canonical', 'href' => 'http://example.com' 277 ] ), $headLinks ); 278 279 $this->assertNotContains( Html::element( 'link', [ 280 'rel' => 'canonical', 'href' => 'http://example.comm' 281 ] ), $headLinks ); 282 } 283 284 /** 285 * @covers OutputPage::addScript 286 */ 287 public function testAddScript() { 288 $op = $this->newInstance(); 289 $op->addScript( 'some random string' ); 290 291 $this->assertStringContainsString( 292 "\nsome random string\n", 293 "\n" . $op->getBottomScripts() . "\n" 294 ); 295 } 296 297 /** 298 * @covers OutputPage::addScriptFile 299 */ 300 public function testAddScriptFile() { 301 $op = $this->newInstance(); 302 $op->addScriptFile( '/somescript.js' ); 303 $op->addScriptFile( '//example.com/somescript.js' ); 304 305 $this->assertStringContainsString( 306 "\n" . Html::linkedScript( '/somescript.js', $op->getCSP()->getNonce() ) . 307 Html::linkedScript( '//example.com/somescript.js', $op->getCSP()->getNonce() ) . "\n", 308 "\n" . $op->getBottomScripts() . "\n" 309 ); 310 } 311 312 /** 313 * @covers OutputPage::addInlineScript 314 */ 315 public function testAddInlineScript() { 316 $op = $this->newInstance(); 317 $op->addInlineScript( 'let foo = "bar";' ); 318 $op->addInlineScript( 'alert( foo );' ); 319 320 $this->assertStringContainsString( 321 "\n" . Html::inlineScript( "\nlet foo = \"bar\";\n", $op->getCSP()->getNonce() ) . "\n" . 322 Html::inlineScript( "\nalert( foo );\n", $op->getCSP()->getNonce() ) . "\n", 323 "\n" . $op->getBottomScripts() . "\n" 324 ); 325 } 326 327 // @todo How to test filterModules(), warnModuleTargetFilter(), getModules(), etc.? 328 329 /** 330 * @covers OutputPage::getTarget 331 * @covers OutputPage::setTarget 332 */ 333 public function testSetTarget() { 334 $op = $this->newInstance(); 335 $op->setTarget( 'foo' ); 336 337 $this->assertSame( 'foo', $op->getTarget() ); 338 // @todo What else? Test some actual effect? 339 } 340 341 // @todo How to test addContentOverride(Callback)? 342 343 /** 344 * @covers OutputPage::getHeadItemsArray 345 * @covers OutputPage::addHeadItem 346 * @covers OutputPage::addHeadItems 347 * @covers OutputPage::hasHeadItem 348 */ 349 public function testHeadItems() { 350 $op = $this->newInstance(); 351 $op->addHeadItem( 'a', 'b' ); 352 $op->addHeadItems( [ 'c' => '<d>&', 'e' => 'f', 'a' => 'q' ] ); 353 $op->addHeadItem( 'e', 'g' ); 354 $op->addHeadItems( 'x' ); 355 356 $this->assertSame( [ 'a' => 'q', 'c' => '<d>&', 'e' => 'g', 'x' ], 357 $op->getHeadItemsArray() ); 358 359 $this->assertTrue( $op->hasHeadItem( 'a' ) ); 360 $this->assertTrue( $op->hasHeadItem( 'c' ) ); 361 $this->assertTrue( $op->hasHeadItem( 'e' ) ); 362 $this->assertTrue( $op->hasHeadItem( '0' ) ); 363 364 $this->assertStringContainsString( "\nq\n<d>&\ng\nx\n", 365 '' . $op->headElement( $op->getContext()->getSkin() ) ); 366 } 367 368 /** 369 * @covers OutputPage::getHeadItemsArray 370 * @covers OutputPage::addParserOutputMetadata 371 * @covers OutputPage::addParserOutput 372 */ 373 public function testHeadItemsParserOutput() { 374 $op = $this->newInstance(); 375 $stubPO1 = $this->createParserOutputStub( 'getHeadItems', [ 'a' => 'b' ] ); 376 $op->addParserOutputMetadata( $stubPO1 ); 377 $stubPO2 = $this->createParserOutputStub( 'getHeadItems', 378 [ 'c' => '<d>&', 'e' => 'f', 'a' => 'q' ] ); 379 $op->addParserOutputMetadata( $stubPO2 ); 380 $stubPO3 = $this->createParserOutputStub( 'getHeadItems', [ 'e' => 'g' ] ); 381 $op->addParserOutput( $stubPO3 ); 382 $stubPO4 = $this->createParserOutputStub( 'getHeadItems', [ 'x' ] ); 383 $op->addParserOutputMetadata( $stubPO4 ); 384 385 $this->assertSame( [ 'a' => 'q', 'c' => '<d>&', 'e' => 'g', 'x' ], 386 $op->getHeadItemsArray() ); 387 388 $this->assertTrue( $op->hasHeadItem( 'a' ) ); 389 $this->assertTrue( $op->hasHeadItem( 'c' ) ); 390 $this->assertTrue( $op->hasHeadItem( 'e' ) ); 391 $this->assertTrue( $op->hasHeadItem( '0' ) ); 392 $this->assertFalse( $op->hasHeadItem( 'b' ) ); 393 394 $this->assertStringContainsString( "\nq\n<d>&\ng\nx\n", 395 '' . $op->headElement( $op->getContext()->getSkin() ) ); 396 } 397 398 /** 399 * @covers OutputPage::addParserOutputMetadata 400 * @covers OutputPage::addParserOutput 401 */ 402 public function testCSPParserOutput() { 403 $this->setMwGlobals( [ 'wgCSPHeader' => [] ] ); 404 foreach ( [ 'Default', 'Script', 'Style' ] as $type ) { 405 $op = $this->newInstance(); 406 $ltype = strtolower( $type ); 407 $stubPO1 = $this->createParserOutputStub( "getExtraCSP{$type}Srcs", [ "{$ltype}src.com" ] ); 408 $op->addParserOutputMetadata( $stubPO1 ); 409 $csp = TestingAccessWrapper::newFromObject( $op->getCSP() ); 410 $actual = $csp->makeCSPDirectives( [ 'default-src' => [] ], false ); 411 $regex = '/(^|;)\s*' . $ltype . '-src\s[^;]*' . $ltype . 'src\.com[\s;]/'; 412 $this->assertRegExp( $regex, $actual, $type ); 413 } 414 } 415 416 /** 417 * @covers OutputPage::addBodyClasses 418 */ 419 public function testAddBodyClasses() { 420 $op = $this->newInstance(); 421 $op->addBodyClasses( 'a' ); 422 $op->addBodyClasses( 'mediawiki' ); 423 $op->addBodyClasses( 'b c' ); 424 $op->addBodyClasses( [ 'd', 'e' ] ); 425 $op->addBodyClasses( 'a' ); 426 427 $this->assertStringContainsString( '"a mediawiki b c d e ltr', 428 '' . $op->headElement( $op->getContext()->getSkin() ) ); 429 } 430 431 /** 432 * @covers OutputPage::setArticleBodyOnly 433 * @covers OutputPage::getArticleBodyOnly 434 */ 435 public function testArticleBodyOnly() { 436 $op = $this->newInstance(); 437 $this->assertFalse( $op->getArticleBodyOnly() ); 438 439 $op->setArticleBodyOnly( true ); 440 $this->assertTrue( $op->getArticleBodyOnly() ); 441 442 $op->addHTML( '<b>a</b>' ); 443 444 $this->assertSame( '<b>a</b>', $op->output( true ) ); 445 } 446 447 /** 448 * @covers OutputPage::setProperty 449 * @covers OutputPage::getProperty 450 */ 451 public function testProperties() { 452 $op = $this->newInstance(); 453 454 $this->assertNull( $op->getProperty( 'foo' ) ); 455 456 $op->setProperty( 'foo', 'bar' ); 457 $op->setProperty( 'baz', 'quz' ); 458 459 $this->assertSame( 'bar', $op->getProperty( 'foo' ) ); 460 $this->assertSame( 'quz', $op->getProperty( 'baz' ) ); 461 } 462 463 /** 464 * @dataProvider provideCheckLastModified 465 * 466 * @covers OutputPage::checkLastModified 467 * @covers OutputPage::getCdnCacheEpoch 468 */ 469 public function testCheckLastModified( 470 $timestamp, $ifModifiedSince, $expected, $config = [], $callback = null 471 ) { 472 $request = new FauxRequest(); 473 if ( $ifModifiedSince ) { 474 if ( is_numeric( $ifModifiedSince ) ) { 475 // Unix timestamp 476 $ifModifiedSince = date( 'D, d M Y H:i:s', $ifModifiedSince ) . ' GMT'; 477 } 478 $request->setHeader( 'If-Modified-Since', $ifModifiedSince ); 479 } 480 481 if ( !isset( $config['CacheEpoch'] ) ) { 482 // Make sure it's not too recent 483 $config['CacheEpoch'] = '20000101000000'; 484 } 485 486 $op = $this->newInstance( $config, $request ); 487 488 if ( $callback ) { 489 $callback( $op, $this ); 490 } 491 492 // Avoid a complaint about not being able to disable compression 493 Wikimedia\suppressWarnings(); 494 try { 495 $this->assertEquals( $expected, $op->checkLastModified( $timestamp ) ); 496 } finally { 497 Wikimedia\restoreWarnings(); 498 } 499 } 500 501 public function provideCheckLastModified() { 502 $lastModified = time() - 3600; 503 return [ 504 'Timestamp 0' => 505 [ '0', $lastModified, false ], 506 'Timestamp Unix epoch' => 507 [ '19700101000000', $lastModified, false ], 508 'Timestamp same as If-Modified-Since' => 509 [ $lastModified, $lastModified, true ], 510 'Timestamp one second after If-Modified-Since' => 511 [ $lastModified + 1, $lastModified, false ], 512 'No If-Modified-Since' => 513 [ $lastModified + 1, null, false ], 514 'Malformed If-Modified-Since' => 515 [ $lastModified + 1, 'GIBBERING WOMBATS !!!', false ], 516 'Non-standard IE-style If-Modified-Since' => 517 [ $lastModified, date( 'D, d M Y H:i:s', $lastModified ) . ' GMT; length=5202', 518 true ], 519 // @todo Should we fix this behavior to match the spec? Probably no reason to. 520 'If-Modified-Since not per spec but we accept it anyway because strtotime does' => 521 [ $lastModified, "@$lastModified", true ], 522 '$wgCachePages = false' => 523 [ $lastModified, $lastModified, false, [ 'CachePages' => false ] ], 524 '$wgCacheEpoch' => 525 [ $lastModified, $lastModified, false, 526 [ 'CacheEpoch' => wfTimestamp( TS_MW, $lastModified + 1 ) ] ], 527 'Recently-touched user' => 528 [ $lastModified, $lastModified, false, [], 529 function ( OutputPage $op ) { 530 $op->getContext()->setUser( $this->getTestUser()->getUser() ); 531 } ], 532 'After CDN expiry' => 533 [ $lastModified, $lastModified, false, 534 [ 'UseCdn' => true, 'CdnMaxAge' => 3599 ] ], 535 'Hook allows cache use' => 536 [ $lastModified + 1, $lastModified, true, [], 537 static function ( $op, $that ) { 538 $that->setTemporaryHook( 'OutputPageCheckLastModified', 539 static function ( &$modifiedTimes ) { 540 $modifiedTimes = [ 1 ]; 541 } 542 ); 543 } ], 544 'Hooks prohibits cache use' => 545 [ $lastModified, $lastModified, false, [], 546 static function ( $op, $that ) { 547 $that->setTemporaryHook( 'OutputPageCheckLastModified', 548 static function ( &$modifiedTimes ) { 549 $modifiedTimes = [ max( $modifiedTimes ) + 1 ]; 550 } 551 ); 552 } ], 553 ]; 554 } 555 556 /** 557 * @dataProvider provideCdnCacheEpoch 558 * 559 * @covers OutputPage::getCdnCacheEpoch 560 */ 561 public function testCdnCacheEpoch( $params ) { 562 $out = TestingAccessWrapper::newFromObject( $this->newInstance() ); 563 $reqTime = strtotime( $params['reqTime'] ); 564 $pageTime = strtotime( $params['pageTime'] ); 565 $actual = max( $pageTime, $out->getCdnCacheEpoch( $reqTime, $params['maxAge'] ) ); 566 567 $this->assertEquals( 568 $params['expect'], 569 gmdate( DateTime::ATOM, $actual ), 570 'cdn epoch' 571 ); 572 } 573 574 public static function provideCdnCacheEpoch() { 575 $base = [ 576 'pageTime' => '2011-04-01T12:00:00+00:00', 577 'maxAge' => 24 * 3600, 578 ]; 579 return [ 580 'after 1s' => [ $base + [ 581 'reqTime' => '2011-04-01T12:00:01+00:00', 582 'expect' => '2011-04-01T12:00:00+00:00', 583 ] ], 584 'after 23h' => [ $base + [ 585 'reqTime' => '2011-04-02T11:00:00+00:00', 586 'expect' => '2011-04-01T12:00:00+00:00', 587 ] ], 588 'after 24h and a bit' => [ $base + [ 589 'reqTime' => '2011-04-02T12:34:56+00:00', 590 'expect' => '2011-04-01T12:34:56+00:00', 591 ] ], 592 'after a year' => [ $base + [ 593 'reqTime' => '2012-05-06T00:12:07+00:00', 594 'expect' => '2012-05-05T00:12:07+00:00', 595 ] ], 596 ]; 597 } 598 599 // @todo How to test setLastModified? 600 601 /** 602 * @covers OutputPage::setRobotPolicy 603 * @covers OutputPage::getHeadLinksArray 604 */ 605 public function testSetRobotPolicy() { 606 $op = $this->newInstance(); 607 $op->setRobotPolicy( 'noindex, nofollow' ); 608 609 $links = $op->getHeadLinksArray(); 610 $this->assertContains( '<meta name="robots" content="noindex,nofollow"/>', $links ); 611 } 612 613 /** 614 * @covers OutputPage::setRobotPolicy 615 * @covers OutputPage::getRobotPolicy 616 */ 617 public function testGetRobotPolicy() { 618 $op = $this->newInstance(); 619 $op->setRobotPolicy( 'noindex, follow' ); 620 621 $policy = $op->getRobotPolicy(); 622 $this->assertSame( 'noindex,follow', $policy ); 623 } 624 625 /** 626 * @covers OutputPage::setIndexPolicy 627 * @covers OutputPage::setFollowPolicy 628 * @covers OutputPage::getHeadLinksArray 629 */ 630 public function testSetIndexFollowPolicies() { 631 $op = $this->newInstance(); 632 $op->setIndexPolicy( 'noindex' ); 633 $op->setFollowPolicy( 'nofollow' ); 634 635 $links = $op->getHeadLinksArray(); 636 $this->assertContains( '<meta name="robots" content="noindex,nofollow"/>', $links ); 637 } 638 639 private function extractHTMLTitle( OutputPage $op ) { 640 $html = $op->headElement( $op->getContext()->getSkin() ); 641 642 // OutputPage should always output the title in a nice format such that regexes will work 643 // fine. If it doesn't, we'll fail the tests. 644 preg_match_all( '!<title>(.*?)</title>!', $html, $matches ); 645 646 $this->assertLessThanOrEqual( 1, count( $matches[1] ), 'More than one <title>!' ); 647 648 if ( !count( $matches[1] ) ) { 649 return null; 650 } 651 652 return $matches[1][0]; 653 } 654 655 /** 656 * Shorthand for getting the text of a message, in content language. 657 * @param MessageLocalizer $op 658 * @param mixed ...$msgParams 659 * @return string 660 */ 661 private static function getMsgText( MessageLocalizer $op, ...$msgParams ) { 662 return $op->msg( ...$msgParams )->inContentLanguage()->text(); 663 } 664 665 /** 666 * @covers OutputPage::setHTMLTitle 667 * @covers OutputPage::getHTMLTitle 668 */ 669 public function testHTMLTitle() { 670 $op = $this->newInstance(); 671 672 // Default 673 $this->assertSame( '', $op->getHTMLTitle() ); 674 $this->assertSame( '', $op->getPageTitle() ); 675 $this->assertSame( 676 $this->getMsgText( $op, 'pagetitle', '' ), 677 $this->extractHTMLTitle( $op ) 678 ); 679 680 // Set to string 681 $op->setHTMLTitle( 'Potatoes will eat me' ); 682 683 $this->assertSame( 'Potatoes will eat me', $op->getHTMLTitle() ); 684 $this->assertSame( 'Potatoes will eat me', $this->extractHTMLTitle( $op ) ); 685 // Shouldn't have changed the page title 686 $this->assertSame( '', $op->getPageTitle() ); 687 688 // Set to message 689 $msg = $op->msg( 'mainpage' ); 690 691 $op->setHTMLTitle( $msg ); 692 $this->assertSame( $msg->text(), $op->getHTMLTitle() ); 693 $this->assertSame( $msg->text(), $this->extractHTMLTitle( $op ) ); 694 $this->assertSame( '', $op->getPageTitle() ); 695 } 696 697 /** 698 * @covers OutputPage::setRedirectedFrom 699 */ 700 public function testSetRedirectedFrom() { 701 $op = $this->newInstance(); 702 703 $op->setRedirectedFrom( Title::newFromText( 'Talk:Some page' ) ); 704 $this->assertSame( 'Talk:Some_page', $op->getJSVars()['wgRedirectedFrom'] ); 705 } 706 707 /** 708 * @covers OutputPage::setPageTitle 709 * @covers OutputPage::getPageTitle 710 */ 711 public function testPageTitle() { 712 // We don't test the actual HTML output anywhere, because that's up to the skin. 713 $op = $this->newInstance(); 714 715 // Test default 716 $this->assertSame( '', $op->getPageTitle() ); 717 $this->assertSame( '', $op->getHTMLTitle() ); 718 719 // Test set to plain text 720 $op->setPageTitle( 'foobar' ); 721 722 $this->assertSame( 'foobar', $op->getPageTitle() ); 723 // HTML title should change as well 724 $this->assertSame( $this->getMsgText( $op, 'pagetitle', 'foobar' ), $op->getHTMLTitle() ); 725 726 // Test set to text with good and bad HTML. We don't try to be comprehensive here, that 727 // belongs in Sanitizer tests. 728 $op->setPageTitle( '<script>a</script>&<i>b</i>' ); 729 730 $this->assertSame( '<script>a</script>&<i>b</i>', $op->getPageTitle() ); 731 $this->assertSame( 732 $this->getMsgText( $op, 'pagetitle', '<script>a</script>&b' ), 733 $op->getHTMLTitle() 734 ); 735 736 // Test set to message 737 $text = $this->getMsgText( $op, 'mainpage' ); 738 739 $op->setPageTitle( $op->msg( 'mainpage' )->inContentLanguage() ); 740 $this->assertSame( $text, $op->getPageTitle() ); 741 $this->assertSame( $this->getMsgText( $op, 'pagetitle', $text ), $op->getHTMLTitle() ); 742 } 743 744 /** 745 * @covers OutputPage::setTitle 746 */ 747 public function testSetTitle() { 748 $op = $this->newInstance(); 749 750 $this->assertSame( 'My test page', $op->getTitle()->getPrefixedText() ); 751 752 $op->setTitle( Title::newFromText( 'Another test page' ) ); 753 754 $this->assertSame( 'Another test page', $op->getTitle()->getPrefixedText() ); 755 } 756 757 /** 758 * @covers OutputPage::setSubtitle 759 * @covers OutputPage::clearSubtitle 760 * @covers OutputPage::addSubtitle 761 * @covers OutputPage::getSubtitle 762 */ 763 public function testSubtitle() { 764 $op = $this->newInstance(); 765 766 $this->assertSame( '', $op->getSubtitle() ); 767 768 $op->addSubtitle( '<b>foo</b>' ); 769 770 $this->assertSame( '<b>foo</b>', $op->getSubtitle() ); 771 772 $op->addSubtitle( $op->msg( 'mainpage' )->inContentLanguage() ); 773 774 $this->assertSame( 775 "<b>foo</b><br />\n\t\t\t\t" . $this->getMsgText( $op, 'mainpage' ), 776 $op->getSubtitle() 777 ); 778 779 $op->setSubtitle( 'There can be only one' ); 780 781 $this->assertSame( 'There can be only one', $op->getSubtitle() ); 782 783 $op->clearSubtitle(); 784 785 $this->assertSame( '', $op->getSubtitle() ); 786 } 787 788 /** 789 * @dataProvider provideBacklinkSubtitle 790 * 791 * @covers OutputPage::buildBacklinkSubtitle 792 */ 793 public function testBuildBacklinkSubtitle( $titles, $queries, $contains, $notContains ) { 794 if ( count( $titles ) > 1 ) { 795 // Not applicable 796 $this->assertTrue( true ); 797 return; 798 } 799 800 $title = Title::newFromText( $titles[0] ); 801 $query = $queries[0]; 802 803 $this->editPage( 'Page 1', '' ); 804 $this->editPage( 'Page 2', '#REDIRECT [[Page 1]]' ); 805 806 $str = OutputPage::buildBacklinkSubtitle( $title, $query )->text(); 807 808 foreach ( $contains as $substr ) { 809 $this->assertStringContainsString( $substr, $str ); 810 } 811 812 foreach ( $notContains as $substr ) { 813 $this->assertStringNotContainsString( $substr, $str ); 814 } 815 } 816 817 /** 818 * @dataProvider provideBacklinkSubtitle 819 * 820 * @covers OutputPage::addBacklinkSubtitle 821 * @covers OutputPage::getSubtitle 822 */ 823 public function testAddBacklinkSubtitle( $titles, $queries, $contains, $notContains ) { 824 $this->editPage( 'Page 1', '' ); 825 $this->editPage( 'Page 2', '#REDIRECT [[Page 1]]' ); 826 827 $op = $this->newInstance(); 828 foreach ( $titles as $i => $unused ) { 829 $op->addBacklinkSubtitle( Title::newFromText( $titles[$i] ), $queries[$i] ); 830 } 831 832 $str = $op->getSubtitle(); 833 834 foreach ( $contains as $substr ) { 835 $this->assertStringContainsString( $substr, $str ); 836 } 837 838 foreach ( $notContains as $substr ) { 839 $this->assertStringNotContainsString( $substr, $str ); 840 } 841 } 842 843 public function provideBacklinkSubtitle() { 844 return [ 845 [ 846 [ 'Page 1' ], 847 [ [] ], 848 [ 'Page 1' ], 849 [ 'redirect', 'Page 2' ], 850 ], 851 [ 852 [ 'Page 2' ], 853 [ [] ], 854 [ 'redirect=no' ], 855 [ 'Page 1' ], 856 ], 857 [ 858 [ 'Page 1' ], 859 [ [ 'action' => 'edit' ] ], 860 [ 'action=edit' ], 861 [], 862 ], 863 [ 864 [ 'Page 1', 'Page 2' ], 865 [ [], [] ], 866 [ 'Page 1', 'Page 2', "<br />\n\t\t\t\t" ], 867 [], 868 ], 869 // @todo Anything else to test? 870 ]; 871 } 872 873 /** 874 * @covers OutputPage::setPrintable 875 * @covers OutputPage::isPrintable 876 */ 877 public function testPrintable() { 878 $op = $this->newInstance(); 879 880 $this->assertFalse( $op->isPrintable() ); 881 882 $op->setPrintable(); 883 884 $this->assertTrue( $op->isPrintable() ); 885 } 886 887 /** 888 * @covers OutputPage::disable 889 * @covers OutputPage::isDisabled 890 */ 891 public function testDisable() { 892 $op = $this->newInstance(); 893 894 $this->assertFalse( $op->isDisabled() ); 895 $this->assertNotSame( '', $op->output( true ) ); 896 897 $op->disable(); 898 899 $this->assertTrue( $op->isDisabled() ); 900 $this->assertSame( '', $op->output( true ) ); 901 } 902 903 /** 904 * @covers OutputPage::showNewSectionLink 905 * @covers OutputPage::addParserOutputMetadata 906 * @covers OutputPage::addParserOutput 907 */ 908 public function testShowNewSectionLink() { 909 $op = $this->newInstance(); 910 911 $this->assertFalse( $op->showNewSectionLink() ); 912 913 $pOut1 = $this->createParserOutputStub( 'getNewSection', true ); 914 $op->addParserOutputMetadata( $pOut1 ); 915 $this->assertTrue( $op->showNewSectionLink() ); 916 917 $pOut2 = $this->createParserOutputStub( 'getNewSection', false ); 918 $op->addParserOutput( $pOut2 ); 919 $this->assertFalse( $op->showNewSectionLink() ); 920 } 921 922 /** 923 * @covers OutputPage::forceHideNewSectionLink 924 * @covers OutputPage::addParserOutputMetadata 925 * @covers OutputPage::addParserOutput 926 */ 927 public function testForceHideNewSectionLink() { 928 $op = $this->newInstance(); 929 930 $this->assertFalse( $op->forceHideNewSectionLink() ); 931 932 $pOut1 = $this->createParserOutputStub( 'getHideNewSection', true ); 933 $op->addParserOutputMetadata( $pOut1 ); 934 $this->assertTrue( $op->forceHideNewSectionLink() ); 935 936 $pOut2 = $this->createParserOutputStub( 'getHideNewSection', false ); 937 $op->addParserOutput( $pOut2 ); 938 $this->assertFalse( $op->forceHideNewSectionLink() ); 939 } 940 941 /** 942 * @covers OutputPage::setSyndicated 943 * @covers OutputPage::isSyndicated 944 */ 945 public function testSetSyndicated() { 946 $op = $this->newInstance( [ 'Feed' => true ] ); 947 $this->assertFalse( $op->isSyndicated() ); 948 949 $op->setSyndicated(); 950 $this->assertTrue( $op->isSyndicated() ); 951 952 $op->setSyndicated( false ); 953 $this->assertFalse( $op->isSyndicated() ); 954 955 $op = $this->newInstance(); // Feed => false by default 956 $this->assertFalse( $op->isSyndicated() ); 957 958 $op->setSyndicated(); 959 $this->assertFalse( $op->isSyndicated() ); 960 } 961 962 /** 963 * @covers OutputPage::isSyndicated 964 * @covers OutputPage::setFeedAppendQuery 965 * @covers OutputPage::addFeedLink 966 * @covers OutputPage::getSyndicationLinks() 967 */ 968 public function testFeedLinks() { 969 $op = $this->newInstance( [ 'Feed' => true ] ); 970 $this->assertSame( [], $op->getSyndicationLinks() ); 971 972 $op->addFeedLink( 'not a supported format', 'abc' ); 973 $this->assertFalse( $op->isSyndicated() ); 974 $this->assertSame( [], $op->getSyndicationLinks() ); 975 976 $feedTypes = $op->getConfig()->get( 'AdvertisedFeedTypes' ); 977 978 $op->addFeedLink( $feedTypes[0], 'def' ); 979 $this->assertTrue( $op->isSyndicated() ); 980 $this->assertSame( [ $feedTypes[0] => 'def' ], $op->getSyndicationLinks() ); 981 982 $op->setFeedAppendQuery( false ); 983 $expected = []; 984 foreach ( $feedTypes as $type ) { 985 $expected[$type] = $op->getTitle()->getLocalURL( "feed=$type" ); 986 } 987 $this->assertSame( $expected, $op->getSyndicationLinks() ); 988 989 $op->setFeedAppendQuery( 'apples=oranges' ); 990 foreach ( $feedTypes as $type ) { 991 $expected[$type] = $op->getTitle()->getLocalURL( "feed=$type&apples=oranges" ); 992 } 993 $this->assertSame( $expected, $op->getSyndicationLinks() ); 994 995 $op = $this->newInstance(); // Feed => false by default 996 $this->assertSame( [], $op->getSyndicationLinks() ); 997 998 $op->addFeedLink( $feedTypes[0], 'def' ); 999 $this->assertFalse( $op->isSyndicated() ); 1000 $this->assertSame( [], $op->getSyndicationLinks() ); 1001 } 1002 1003 /** 1004 * @covers OutputPage::setArticleFlag 1005 * @covers OutputPage::isArticle 1006 * @covers OutputPage::setArticleRelated 1007 * @covers OutputPage::isArticleRelated 1008 */ 1009 public function testArticleFlags() { 1010 $op = $this->newInstance(); 1011 $this->assertFalse( $op->isArticle() ); 1012 $this->assertTrue( $op->isArticleRelated() ); 1013 1014 $op->setArticleRelated( false ); 1015 $this->assertFalse( $op->isArticle() ); 1016 $this->assertFalse( $op->isArticleRelated() ); 1017 1018 $op->setArticleFlag( true ); 1019 $this->assertTrue( $op->isArticle() ); 1020 $this->assertTrue( $op->isArticleRelated() ); 1021 1022 $op->setArticleFlag( false ); 1023 $this->assertFalse( $op->isArticle() ); 1024 $this->assertTrue( $op->isArticleRelated() ); 1025 1026 $op->setArticleFlag( true ); 1027 $op->setArticleRelated( false ); 1028 $this->assertFalse( $op->isArticle() ); 1029 $this->assertFalse( $op->isArticleRelated() ); 1030 } 1031 1032 /** 1033 * @covers OutputPage::addLanguageLinks 1034 * @covers OutputPage::setLanguageLinks 1035 * @covers OutputPage::getLanguageLinks 1036 * @covers OutputPage::addParserOutputMetadata 1037 * @covers OutputPage::addParserOutput 1038 */ 1039 public function testLanguageLinks() { 1040 $op = $this->newInstance(); 1041 $this->assertSame( [], $op->getLanguageLinks() ); 1042 1043 $op->addLanguageLinks( [ 'fr:A', 'it:B' ] ); 1044 $this->assertSame( [ 'fr:A', 'it:B' ], $op->getLanguageLinks() ); 1045 1046 $op->addLanguageLinks( [ 'de:C', 'es:D' ] ); 1047 $this->assertSame( [ 'fr:A', 'it:B', 'de:C', 'es:D' ], $op->getLanguageLinks() ); 1048 1049 $op->setLanguageLinks( [ 'pt:E' ] ); 1050 $this->assertSame( [ 'pt:E' ], $op->getLanguageLinks() ); 1051 1052 $pOut1 = $this->createParserOutputStub( 'getLanguageLinks', [ 'he:F', 'ar:G' ] ); 1053 $op->addParserOutputMetadata( $pOut1 ); 1054 $this->assertSame( [ 'pt:E', 'he:F', 'ar:G' ], $op->getLanguageLinks() ); 1055 1056 $pOut2 = $this->createParserOutputStub( 'getLanguageLinks', [ 'pt:H' ] ); 1057 $op->addParserOutput( $pOut2 ); 1058 $this->assertSame( [ 'pt:E', 'he:F', 'ar:G', 'pt:H' ], $op->getLanguageLinks() ); 1059 } 1060 1061 // @todo Are these category links tests too abstract and complicated for what they test? Would 1062 // it make sense to just write out all the tests by hand with maybe some copy-and-paste? 1063 1064 /** 1065 * @dataProvider provideGetCategories 1066 * 1067 * @covers OutputPage::addCategoryLinks 1068 * @covers OutputPage::getCategories 1069 * @covers OutputPage::getCategoryLinks 1070 * 1071 * @param array $args Array of form [ category name => sort key ] 1072 * @param array $fakeResults Array of form [ category name => value to return from mocked 1073 * LinkBatch ] 1074 * @param callable|null $variantLinkCallback Callback to replace findVariantLink() call 1075 * @param array $expectedNormal Expected return value of getCategoryLinks['normal'] 1076 * @param array $expectedHidden Expected return value of getCategoryLinks['hidden'] 1077 */ 1078 public function testAddCategoryLinks( 1079 array $args, array $fakeResults, ?callable $variantLinkCallback, 1080 array $expectedNormal, array $expectedHidden 1081 ) { 1082 $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'add' ); 1083 $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'add' ); 1084 1085 $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback ); 1086 1087 $op->addCategoryLinks( $args ); 1088 1089 $this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden ); 1090 $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden ); 1091 } 1092 1093 /** 1094 * @dataProvider provideGetCategories 1095 * 1096 * @covers OutputPage::addCategoryLinks 1097 * @covers OutputPage::getCategories 1098 * @covers OutputPage::getCategoryLinks 1099 */ 1100 public function testAddCategoryLinksOneByOne( 1101 array $args, array $fakeResults, ?callable $variantLinkCallback, 1102 array $expectedNormal, array $expectedHidden 1103 ) { 1104 if ( count( $args ) <= 1 ) { 1105 // @todo Should this be skipped instead of passed? 1106 $this->assertTrue( true ); 1107 return; 1108 } 1109 1110 $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'onebyone' ); 1111 $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'onebyone' ); 1112 1113 $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback ); 1114 1115 foreach ( $args as $key => $val ) { 1116 $op->addCategoryLinks( [ $key => $val ] ); 1117 } 1118 1119 $this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden ); 1120 $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden ); 1121 } 1122 1123 /** 1124 * @dataProvider provideGetCategories 1125 * 1126 * @covers OutputPage::setCategoryLinks 1127 * @covers OutputPage::getCategories 1128 * @covers OutputPage::getCategoryLinks 1129 */ 1130 public function testSetCategoryLinks( 1131 array $args, array $fakeResults, ?callable $variantLinkCallback, 1132 array $expectedNormal, array $expectedHidden 1133 ) { 1134 $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'set' ); 1135 $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'set' ); 1136 1137 $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback ); 1138 1139 $op->setCategoryLinks( [ 'Initial page' => 'Initial page' ] ); 1140 $op->setCategoryLinks( $args ); 1141 1142 // We don't reset the categories, for some reason, only the links 1143 $expectedNormalCats = array_merge( [ 'Initial page' ], $expectedNormal ); 1144 $expectedCats = array_merge( $expectedHidden, $expectedNormalCats ); 1145 1146 $this->doCategoryAsserts( $op, $expectedNormalCats, $expectedHidden ); 1147 $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden ); 1148 } 1149 1150 /** 1151 * @dataProvider provideGetCategories 1152 * 1153 * @covers OutputPage::addParserOutputMetadata 1154 * @covers OutputPage::addParserOutput 1155 * @covers OutputPage::getCategories 1156 * @covers OutputPage::getCategoryLinks 1157 */ 1158 public function testParserOutputCategoryLinks( 1159 array $args, array $fakeResults, ?callable $variantLinkCallback, 1160 array $expectedNormal, array $expectedHidden 1161 ) { 1162 $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'pout' ); 1163 $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'pout' ); 1164 1165 $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback ); 1166 1167 $stubPO = $this->createParserOutputStub( 'getCategories', $args ); 1168 1169 // addParserOutput and addParserOutputMetadata should behave identically for us, so 1170 // alternate to get coverage for both without adding extra tests 1171 static $idx = 0; 1172 $idx++; 1173 $method = [ 'addParserOutputMetadata', 'addParserOutput' ][$idx % 2]; 1174 $op->$method( $stubPO ); 1175 1176 $this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden ); 1177 $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden ); 1178 } 1179 1180 /** 1181 * We allow different expectations for different tests as an associative array, like 1182 * [ 'set' => [ ... ], 'default' => [ ... ] ] if setCategoryLinks() will give a different 1183 * result. 1184 * @param array $expected 1185 * @param string $key 1186 * @return array 1187 */ 1188 private function extractExpectedCategories( array $expected, $key ) { 1189 if ( !$expected || isset( $expected[0] ) ) { 1190 return $expected; 1191 } 1192 return $expected[$key] ?? $expected['default']; 1193 } 1194 1195 private function setupCategoryTests( 1196 array $fakeResults, callable $variantLinkCallback = null 1197 ) : OutputPage { 1198 $this->setMwGlobals( 'wgUsePigLatinVariant', true ); 1199 1200 if ( $variantLinkCallback ) { 1201 $mockContLang = $this->createMock( Language::class ); 1202 $mockContLang 1203 ->expects( $this->any() ) 1204 ->method( 'convertHtml' ) 1205 ->will( $this->returnCallback( static function ( $arg ) { 1206 return $arg; 1207 } ) ); 1208 1209 $mockLanguageConverter = $this 1210 ->createMock( ILanguageConverter::class ); 1211 $mockLanguageConverter 1212 ->expects( $this->any() ) 1213 ->method( 'findVariantLink' ) 1214 ->will( $this->returnCallback( $variantLinkCallback ) ); 1215 1216 $languageConverterFactory = $this 1217 ->createMock( LanguageConverterFactory::class ); 1218 $languageConverterFactory 1219 ->expects( $this->any() ) 1220 ->method( 'getLanguageConverter' ) 1221 ->willReturn( $mockLanguageConverter ); 1222 $this->setService( 1223 'LanguageConverterFactory', 1224 $languageConverterFactory 1225 ); 1226 } 1227 1228 $op = $this->getMockBuilder( OutputPage::class ) 1229 ->setConstructorArgs( [ new RequestContext() ] ) 1230 ->setMethods( [ 'addCategoryLinksToLBAndGetResult', 'getTitle' ] ) 1231 ->getMock(); 1232 1233 $title = Title::newFromText( 'My test page' ); 1234 $op->expects( $this->any() ) 1235 ->method( 'getTitle' ) 1236 ->will( $this->returnValue( $title ) ); 1237 1238 $op->expects( $this->any() ) 1239 ->method( 'addCategoryLinksToLBAndGetResult' ) 1240 ->will( $this->returnCallback( static function ( array $categories ) use ( $fakeResults ) { 1241 $return = []; 1242 foreach ( $categories as $category => $unused ) { 1243 if ( isset( $fakeResults[$category] ) ) { 1244 $return[] = $fakeResults[$category]; 1245 } 1246 } 1247 return new FakeResultWrapper( $return ); 1248 } ) ); 1249 1250 $this->assertSame( [], $op->getCategories() ); 1251 1252 return $op; 1253 } 1254 1255 private function doCategoryAsserts( OutputPage $op, $expectedNormal, $expectedHidden ) { 1256 $this->assertSame( array_merge( $expectedHidden, $expectedNormal ), $op->getCategories() ); 1257 $this->assertSame( $expectedNormal, $op->getCategories( 'normal' ) ); 1258 $this->assertSame( $expectedHidden, $op->getCategories( 'hidden' ) ); 1259 } 1260 1261 private function doCategoryLinkAsserts( OutputPage $op, $expectedNormal, $expectedHidden ) { 1262 $catLinks = $op->getCategoryLinks(); 1263 $this->assertCount( (bool)$expectedNormal + (bool)$expectedHidden, $catLinks ); 1264 if ( $expectedNormal ) { 1265 $this->assertSame( count( $expectedNormal ), count( $catLinks['normal'] ) ); 1266 } 1267 if ( $expectedHidden ) { 1268 $this->assertSame( count( $expectedHidden ), count( $catLinks['hidden'] ) ); 1269 } 1270 1271 foreach ( $expectedNormal as $i => $name ) { 1272 $this->assertStringContainsString( $name, $catLinks['normal'][$i] ); 1273 } 1274 foreach ( $expectedHidden as $i => $name ) { 1275 $this->assertStringContainsString( $name, $catLinks['hidden'][$i] ); 1276 } 1277 } 1278 1279 public function provideGetCategories() { 1280 return [ 1281 'No categories' => [ [], [], null, [], [] ], 1282 'Simple test' => [ 1283 [ 'Test1' => 'Some sortkey', 'Test2' => 'A different sortkey' ], 1284 [ 'Test1' => (object)[ 'pp_value' => 1, 'page_title' => 'Test1' ], 1285 'Test2' => (object)[ 'page_title' => 'Test2' ] ], 1286 null, 1287 [ 'Test2' ], 1288 [ 'Test1' ], 1289 ], 1290 'Invalid title' => [ 1291 [ '[' => '[', 'Test' => 'Test' ], 1292 [ 'Test' => (object)[ 'page_title' => 'Test' ] ], 1293 null, 1294 [ 'Test' ], 1295 [], 1296 ], 1297 'Variant link' => [ 1298 [ 'Test' => 'Test', 'Estay' => 'Estay' ], 1299 [ 'Test' => (object)[ 'page_title' => 'Test' ] ], 1300 static function ( &$link, &$title ) { 1301 if ( $link === 'Estay' ) { 1302 $link = 'Test'; 1303 $title = Title::makeTitleSafe( NS_CATEGORY, $link ); 1304 } 1305 }, 1306 // For adding one by one, the variant gets added as well as the original category, 1307 // but if you add them all together the second time gets skipped. 1308 [ 'onebyone' => [ 'Test', 'Test' ], 'default' => [ 'Test' ] ], 1309 [], 1310 ], 1311 ]; 1312 } 1313 1314 /** 1315 * @covers OutputPage::getCategories 1316 */ 1317 public function testGetCategoriesInvalid() { 1318 $this->expectException( InvalidArgumentException::class ); 1319 $this->expectExceptionMessage( 'Invalid category type given: hiddne' ); 1320 1321 $op = $this->newInstance(); 1322 $op->getCategories( 'hiddne' ); 1323 } 1324 1325 // @todo Should we test addCategoryLinksToLBAndGetResult? If so, how? Insert some test rows in 1326 // the DB? 1327 1328 /** 1329 * @covers OutputPage::setIndicators 1330 * @covers OutputPage::getIndicators 1331 * @covers OutputPage::addParserOutputMetadata 1332 * @covers OutputPage::addParserOutput 1333 */ 1334 public function testIndicators() { 1335 $op = $this->newInstance(); 1336 $this->assertSame( [], $op->getIndicators() ); 1337 1338 $op->setIndicators( [] ); 1339 $this->assertSame( [], $op->getIndicators() ); 1340 1341 // Test sorting alphabetically 1342 $op->setIndicators( [ 'b' => 'x', 'a' => 'y' ] ); 1343 $this->assertSame( [ 'a' => 'y', 'b' => 'x' ], $op->getIndicators() ); 1344 1345 // Test overwriting existing keys 1346 $op->setIndicators( [ 'c' => 'z', 'a' => 'w' ] ); 1347 $this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'z' ], $op->getIndicators() ); 1348 1349 // Test with addParserOutputMetadata 1350 $pOut1 = $this->createParserOutputStub( 'getIndicators', [ 'c' => 'u', 'd' => 'v' ] ); 1351 $op->addParserOutputMetadata( $pOut1 ); 1352 $this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'u', 'd' => 'v' ], 1353 $op->getIndicators() ); 1354 1355 // Test with addParserOutput 1356 $pOut2 = $this->createParserOutputStub( 'getIndicators', [ 'a' => '!!!' ] ); 1357 $op->addParserOutput( $pOut2 ); 1358 $this->assertSame( [ 'a' => '!!!', 'b' => 'x', 'c' => 'u', 'd' => 'v' ], 1359 $op->getIndicators() ); 1360 } 1361 1362 /** 1363 * @covers OutputPage::addHelpLink 1364 * @covers OutputPage::getIndicators 1365 */ 1366 public function testAddHelpLink() { 1367 $op = $this->newInstance(); 1368 1369 $op->addHelpLink( 'Manual:PHP unit testing' ); 1370 $indicators = $op->getIndicators(); 1371 $this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) ); 1372 $this->assertStringContainsString( 'Manual:PHP_unit_testing', $indicators['mw-helplink'] ); 1373 1374 $op->addHelpLink( 'https://phpunit.de', true ); 1375 $indicators = $op->getIndicators(); 1376 $this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) ); 1377 $this->assertStringContainsString( 'https://phpunit.de', $indicators['mw-helplink'] ); 1378 $this->assertStringNotContainsString( 'mediawiki', $indicators['mw-helplink'] ); 1379 $this->assertStringNotContainsString( 'Manual:PHP', $indicators['mw-helplink'] ); 1380 } 1381 1382 /** 1383 * @covers OutputPage::prependHTML 1384 * @covers OutputPage::addHTML 1385 * @covers OutputPage::addElement 1386 * @covers OutputPage::clearHTML 1387 * @covers OutputPage::getHTML 1388 */ 1389 public function testBodyHTML() { 1390 $op = $this->newInstance(); 1391 $this->assertSame( '', $op->getHTML() ); 1392 1393 $op->addHTML( 'a' ); 1394 $this->assertSame( 'a', $op->getHTML() ); 1395 1396 $op->addHTML( 'b' ); 1397 $this->assertSame( 'ab', $op->getHTML() ); 1398 1399 $op->prependHTML( 'c' ); 1400 $this->assertSame( 'cab', $op->getHTML() ); 1401 1402 $op->addElement( 'p', [ 'id' => 'foo' ], 'd' ); 1403 $this->assertSame( 'cab<p id="foo">d</p>', $op->getHTML() ); 1404 1405 $op->clearHTML(); 1406 $this->assertSame( '', $op->getHTML() ); 1407 } 1408 1409 /** 1410 * @dataProvider provideRevisionId 1411 * @covers OutputPage::setRevisionId 1412 * @covers OutputPage::getRevisionId 1413 */ 1414 public function testRevisionId( $newVal, $expected ) { 1415 $op = $this->newInstance(); 1416 1417 $this->assertNull( $op->setRevisionId( $newVal ) ); 1418 $this->assertSame( $expected, $op->getRevisionId() ); 1419 $this->assertSame( $expected, $op->setRevisionId( null ) ); 1420 $this->assertNull( $op->getRevisionId() ); 1421 } 1422 1423 public function provideRevisionId() { 1424 return [ 1425 [ null, null ], 1426 [ 7, 7 ], 1427 [ -1, -1 ], 1428 [ 3.2, 3 ], 1429 [ '0', 0 ], 1430 [ '32% finished', 32 ], 1431 [ false, 0 ], 1432 ]; 1433 } 1434 1435 /** 1436 * @covers OutputPage::setRevisionTimestamp 1437 * @covers OutputPage::getRevisionTimestamp 1438 */ 1439 public function testRevisionTimestamp() { 1440 $op = $this->newInstance(); 1441 $this->assertNull( $op->getRevisionTimestamp() ); 1442 1443 $this->assertNull( $op->setRevisionTimestamp( 'abc' ) ); 1444 $this->assertSame( 'abc', $op->getRevisionTimestamp() ); 1445 $this->assertSame( 'abc', $op->setRevisionTimestamp( null ) ); 1446 $this->assertNull( $op->getRevisionTimestamp() ); 1447 } 1448 1449 /** 1450 * @covers OutputPage::setFileVersion 1451 * @covers OutputPage::getFileVersion 1452 */ 1453 public function testFileVersion() { 1454 $op = $this->newInstance(); 1455 $this->assertNull( $op->getFileVersion() ); 1456 1457 $stubFile = $this->createMock( File::class ); 1458 $stubFile->method( 'exists' )->willReturn( true ); 1459 $stubFile->method( 'getTimestamp' )->willReturn( '12211221123321' ); 1460 $stubFile->method( 'getSha1' )->willReturn( 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' ); 1461 1462 /** @var File $stubFile */ 1463 $op->setFileVersion( $stubFile ); 1464 1465 $this->assertEquals( 1466 [ 'time' => '12211221123321', 'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' ], 1467 $op->getFileVersion() 1468 ); 1469 1470 $stubMissingFile = $this->createMock( File::class ); 1471 $stubMissingFile->method( 'exists' )->willReturn( false ); 1472 1473 /** @var File $stubMissingFile */ 1474 $op->setFileVersion( $stubMissingFile ); 1475 $this->assertNull( $op->getFileVersion() ); 1476 1477 $op->setFileVersion( $stubFile ); 1478 $this->assertNotNull( $op->getFileVersion() ); 1479 1480 $op->setFileVersion( null ); 1481 $this->assertNull( $op->getFileVersion() ); 1482 } 1483 1484 /** 1485 * Call either with arguments $methodName, $returnValue; or an array 1486 * [ $methodName => $returnValue, $methodName => $returnValue, ... ] 1487 * @param mixed ...$args 1488 * @return ParserOutput 1489 */ 1490 private function createParserOutputStub( ...$args ) : ParserOutput { 1491 if ( count( $args ) === 0 ) { 1492 $retVals = []; 1493 } elseif ( count( $args ) === 1 ) { 1494 $retVals = $args[0]; 1495 } elseif ( count( $args ) === 2 ) { 1496 $retVals = [ $args[0] => $args[1] ]; 1497 } 1498 $pOut = $this->createMock( ParserOutput::class ); 1499 foreach ( $retVals as $method => $retVal ) { 1500 $pOut->method( $method )->willReturn( $retVal ); 1501 } 1502 1503 $arrayReturningMethods = [ 1504 'getCategories', 1505 'getFileSearchOptions', 1506 'getHeadItems', 1507 'getImages', 1508 'getIndicators', 1509 'getLanguageLinks', 1510 'getOutputHooks', 1511 'getTemplateIds', 1512 'getExtraCSPDefaultSrcs', 1513 'getExtraCSPStyleSrcs', 1514 'getExtraCSPScriptSrcs', 1515 ]; 1516 1517 foreach ( $arrayReturningMethods as $method ) { 1518 $pOut->method( $method )->willReturn( [] ); 1519 } 1520 1521 return $pOut; 1522 } 1523 1524 /** 1525 * @covers OutputPage::getTemplateIds 1526 * @covers OutputPage::addParserOutputMetadata 1527 * @covers OutputPage::addParserOutput 1528 */ 1529 public function testTemplateIds() { 1530 $op = $this->newInstance(); 1531 $this->assertSame( [], $op->getTemplateIds() ); 1532 1533 // Test with no template id's 1534 $stubPOEmpty = $this->createParserOutputStub(); 1535 $op->addParserOutputMetadata( $stubPOEmpty ); 1536 $this->assertSame( [], $op->getTemplateIds() ); 1537 1538 // Test with some arbitrary template id's 1539 $ids = [ 1540 NS_MAIN => [ 'A' => 3, 'B' => 17 ], 1541 NS_TALK => [ 'C' => 31 ], 1542 NS_MEDIA => [ 'D' => -1 ], 1543 ]; 1544 1545 $stubPO1 = $this->createParserOutputStub( 'getTemplateIds', $ids ); 1546 1547 $op->addParserOutputMetadata( $stubPO1 ); 1548 $this->assertSame( $ids, $op->getTemplateIds() ); 1549 1550 // Test merging with a second set of id's 1551 $stubPO2 = $this->createParserOutputStub( 'getTemplateIds', [ 1552 NS_MAIN => [ 'E' => 1234 ], 1553 NS_PROJECT => [ 'F' => 5678 ], 1554 ] ); 1555 1556 $finalIds = [ 1557 NS_MAIN => [ 'E' => 1234, 'A' => 3, 'B' => 17 ], 1558 NS_TALK => [ 'C' => 31 ], 1559 NS_MEDIA => [ 'D' => -1 ], 1560 NS_PROJECT => [ 'F' => 5678 ], 1561 ]; 1562 1563 $op->addParserOutput( $stubPO2 ); 1564 $this->assertSame( $finalIds, $op->getTemplateIds() ); 1565 1566 // Test merging with an empty set of id's 1567 $op->addParserOutputMetadata( $stubPOEmpty ); 1568 $this->assertSame( $finalIds, $op->getTemplateIds() ); 1569 } 1570 1571 /** 1572 * @covers OutputPage::getFileSearchOptions 1573 * @covers OutputPage::addParserOutputMetadata 1574 * @covers OutputPage::addParserOutput 1575 */ 1576 public function testFileSearchOptions() { 1577 $op = $this->newInstance(); 1578 $this->assertSame( [], $op->getFileSearchOptions() ); 1579 1580 // Test with no files 1581 $stubPOEmpty = $this->createParserOutputStub(); 1582 1583 $op->addParserOutputMetadata( $stubPOEmpty ); 1584 $this->assertSame( [], $op->getFileSearchOptions() ); 1585 1586 // Test with some arbitrary files 1587 $files1 = [ 1588 'A' => [ 'time' => null, 'sha1' => '' ], 1589 'B' => [ 1590 'time' => '12211221123321', 1591 'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05', 1592 ], 1593 ]; 1594 1595 $stubPO1 = $this->createParserOutputStub( 'getFileSearchOptions', $files1 ); 1596 1597 $op->addParserOutput( $stubPO1 ); 1598 $this->assertSame( $files1, $op->getFileSearchOptions() ); 1599 1600 // Test merging with a second set of files 1601 $files2 = [ 1602 'C' => [ 'time' => null, 'sha1' => '' ], 1603 'B' => [ 'time' => null, 'sha1' => '' ], 1604 ]; 1605 1606 $stubPO2 = $this->createParserOutputStub( 'getFileSearchOptions', $files2 ); 1607 1608 $op->addParserOutputMetadata( $stubPO2 ); 1609 $this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() ); 1610 1611 // Test merging with an empty set of files 1612 $op->addParserOutput( $stubPOEmpty ); 1613 $this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() ); 1614 } 1615 1616 /** 1617 * @dataProvider provideAddWikiText 1618 * @covers OutputPage::addWikiTextAsInterface 1619 * @covers OutputPage::wrapWikiTextAsInterface 1620 * @covers OutputPage::addWikiTextAsContent 1621 * @covers OutputPage::getHTML 1622 */ 1623 public function testAddWikiText( $method, array $args, $expected ) { 1624 $op = $this->newInstance(); 1625 $this->assertSame( '', $op->getHTML() ); 1626 1627 if ( in_array( 1628 $method, 1629 [ 'addWikiTextAsInterface', 'addWikiTextAsContent' ] 1630 ) && count( $args ) >= 3 && $args[2] === null ) { 1631 // Special placeholder because we can't get the actual title in the provider 1632 $args[2] = $op->getTitle(); 1633 } 1634 1635 $op->$method( ...$args ); 1636 $this->assertSame( $expected, $op->getHTML() ); 1637 } 1638 1639 public function provideAddWikiText() { 1640 $tests = [ 1641 'addWikiTextAsInterface' => [ 1642 'Simple wikitext' => [ 1643 [ "'''Bold'''" ], 1644 "<p><b>Bold</b>\n</p>", 1645 ], 'Untidy wikitext' => [ 1646 [ "<b>Bold" ], 1647 "<p><b>Bold\n</b></p>", 1648 ], 'List at start' => [ 1649 [ '* List' ], 1650 "<ul><li>List</li></ul>\n", 1651 ], 'List not at start' => [ 1652 [ '* Not a list', false ], 1653 '<p>* Not a list</p>', 1654 ], 'No section edit links' => [ 1655 [ '== Title ==' ], 1656 "<h2><span class=\"mw-headline\" id=\"Title\">Title</span></h2>", 1657 ], 'With title at start' => [ 1658 [ '* {{PAGENAME}}', true, Title::newFromText( 'Talk:Some page' ) ], 1659 "<ul><li>Some page</li></ul>\n", 1660 ], 'With title at start' => [ 1661 [ '* {{PAGENAME}}', false, Title::newFromText( 'Talk:Some page' ), false ], 1662 "<p>* Some page</p>", 1663 ], 'Untidy input' => [ 1664 [ '<b>{{PAGENAME}}', true, Title::newFromText( 'Talk:Some page' ) ], 1665 "<p><b>Some page\n</b></p>", 1666 ], 1667 ], 1668 'addWikiTextAsContent' => [ 1669 'SpecialNewimages' => [ 1670 [ "<p lang='en' dir='ltr'>\nMy message" ], 1671 '<p lang="en" dir="ltr">' . "\nMy message</p>" 1672 ], 'List at start' => [ 1673 [ '* List' ], 1674 "<ul><li>List</li></ul>", 1675 ], 'List not at start' => [ 1676 [ '* <b>Not a list', false ], 1677 '<p>* <b>Not a list</b></p>', 1678 ], 'With title at start' => [ 1679 [ '* {{PAGENAME}}', true, Title::newFromText( 'Talk:Some page' ) ], 1680 "<ul><li>Some page</li></ul>\n", 1681 ], 'With title at start' => [ 1682 [ '* {{PAGENAME}}', false, Title::newFromText( 'Talk:Some page' ), false ], 1683 "<p>* Some page</p>", 1684 ], 'EditPage' => [ 1685 [ "<div class='mw-editintro'>{{PAGENAME}}", true, Title::newFromText( 'Talk:Some page' ) ], 1686 '<div class="mw-editintro">' . "Some page</div>" 1687 ], 1688 ], 1689 'wrapWikiTextAsInterface' => [ 1690 'Simple' => [ 1691 [ 'wrapperClass', 'text' ], 1692 "<div class=\"wrapperClass\"><p>text\n</p></div>" 1693 ], 'Spurious </div>' => [ 1694 [ 'wrapperClass', 'text</div><div>more' ], 1695 "<div class=\"wrapperClass\"><p>text</p><div>more</div></div>" 1696 ], 'Extra newlines would break <p> wrappers' => [ 1697 [ 'two classes', "1\n\n2\n\n3" ], 1698 "<div class=\"two classes\"><p>1\n</p><p>2\n</p><p>3\n</p></div>" 1699 ], 'Other unclosed tags' => [ 1700 [ 'error', 'a<b>c<i>d' ], 1701 "<div class=\"error\"><p>a<b>c<i>d\n</i></b></p></div>" 1702 ], 1703 ], 1704 ]; 1705 1706 // We have to reformat our array to match what PHPUnit wants 1707 $ret = []; 1708 foreach ( $tests as $key => $subarray ) { 1709 foreach ( $subarray as $subkey => $val ) { 1710 $val = array_merge( [ $key ], $val ); 1711 $ret[$subkey] = $val; 1712 } 1713 } 1714 1715 return $ret; 1716 } 1717 1718 /** 1719 * @covers OutputPage::addWikiTextAsInterface 1720 */ 1721 public function testAddWikiTextAsInterfaceNoTitle() { 1722 $this->expectException( MWException::class ); 1723 $this->expectExceptionMessage( 'Title is null' ); 1724 1725 $op = $this->newInstance( [], null, 'notitle' ); 1726 $op->addWikiTextAsInterface( 'a' ); 1727 } 1728 1729 /** 1730 * @covers OutputPage::addWikiTextAsContent 1731 */ 1732 public function testAddWikiTextAsContentNoTitle() { 1733 $this->expectException( MWException::class ); 1734 $this->expectExceptionMessage( 'Title is null' ); 1735 1736 $op = $this->newInstance( [], null, 'notitle' ); 1737 $op->addWikiTextAsContent( 'a' ); 1738 } 1739 1740 /** 1741 * @covers OutputPage::addWikiMsg 1742 */ 1743 public function testAddWikiMsg() { 1744 $msg = wfMessage( 'parentheses' ); 1745 $this->assertSame( '(a)', $msg->rawParams( 'a' )->plain() ); 1746 1747 $op = $this->newInstance(); 1748 $this->assertSame( '', $op->getHTML() ); 1749 $op->addWikiMsg( 'parentheses', "<b>a" ); 1750 // The input is bad unbalanced HTML, but the output is tidied 1751 $this->assertSame( "<p>(<b>a)\n</b></p>", $op->getHTML() ); 1752 } 1753 1754 /** 1755 * @covers OutputPage::wrapWikiMsg 1756 */ 1757 public function testWrapWikiMsg() { 1758 $msg = wfMessage( 'parentheses' ); 1759 $this->assertSame( '(a)', $msg->rawParams( 'a' )->plain() ); 1760 1761 $op = $this->newInstance(); 1762 $this->assertSame( '', $op->getHTML() ); 1763 $op->wrapWikiMsg( '[$1]', [ 'parentheses', "<b>a" ] ); 1764 // The input is bad unbalanced HTML, but the output is tidied 1765 $this->assertSame( "<p>[(<b>a)]\n</b></p>", $op->getHTML() ); 1766 } 1767 1768 /** 1769 * @covers OutputPage::addParserOutputMetadata 1770 * @covers OutputPage::addParserOutput 1771 */ 1772 public function testNoGallery() { 1773 $op = $this->newInstance(); 1774 $this->assertFalse( $op->mNoGallery ); 1775 1776 $stubPO1 = $this->createParserOutputStub( 'getNoGallery', true ); 1777 $op->addParserOutputMetadata( $stubPO1 ); 1778 $this->assertTrue( $op->mNoGallery ); 1779 1780 $stubPO2 = $this->createParserOutputStub( 'getNoGallery', false ); 1781 $op->addParserOutput( $stubPO2 ); 1782 $this->assertFalse( $op->mNoGallery ); 1783 } 1784 1785 private static $parserOutputHookCalled; 1786 1787 /** 1788 * @covers OutputPage::addParserOutputMetadata 1789 */ 1790 public function testParserOutputHooks() { 1791 $op = $this->newInstance(); 1792 $pOut = $this->createParserOutputStub( 'getOutputHooks', [ 1793 [ 'myhook', 'banana' ], 1794 [ 'yourhook', 'kumquat' ], 1795 [ 'theirhook', 'hippopotamus' ], 1796 ] ); 1797 1798 self::$parserOutputHookCalled = []; 1799 1800 $this->setMwGlobals( 'wgParserOutputHooks', [ 1801 'myhook' => function ( OutputPage $innerOp, ParserOutput $innerPOut, $data ) 1802 use ( $op, $pOut ) { 1803 $this->assertSame( $op, $innerOp ); 1804 $this->assertSame( $pOut, $innerPOut ); 1805 $this->assertSame( 'banana', $data ); 1806 self::$parserOutputHookCalled[] = 'closure'; 1807 }, 1808 'yourhook' => [ $this, 'parserOutputHookCallback' ], 1809 'theirhook' => [ __CLASS__, 'parserOutputHookCallbackStatic' ], 1810 'uncalled' => function () { 1811 $this->assertTrue( false ); 1812 }, 1813 ] ); 1814 1815 $op->addParserOutputMetadata( $pOut ); 1816 1817 $this->assertSame( [ 'closure', 'callback', 'static' ], self::$parserOutputHookCalled ); 1818 } 1819 1820 public function parserOutputHookCallback( 1821 OutputPage $op, ParserOutput $pOut, $data 1822 ) { 1823 $this->assertSame( 'kumquat', $data ); 1824 1825 self::$parserOutputHookCalled[] = 'callback'; 1826 } 1827 1828 public static function parserOutputHookCallbackStatic( 1829 OutputPage $op, ParserOutput $pOut, $data 1830 ) { 1831 // All the assert methods are actually static, who knew! 1832 self::assertSame( 'hippopotamus', $data ); 1833 1834 self::$parserOutputHookCalled[] = 'static'; 1835 } 1836 1837 // @todo Make sure to test the following in addParserOutputMetadata() as well when we add tests 1838 // for them: 1839 // * addModules() 1840 // * addModuleStyles() 1841 // * addJsConfigVars() 1842 // * enableOOUI() 1843 // Otherwise those lines of addParserOutputMetadata() will be reported as covered, but we won't 1844 // be testing they actually work. 1845 1846 /** 1847 * @covers OutputPage::addParserOutputText 1848 */ 1849 public function testAddParserOutputText() { 1850 $op = $this->newInstance(); 1851 $this->assertSame( '', $op->getHTML() ); 1852 1853 $pOut = $this->createParserOutputStub( 'getText', '<some text>' ); 1854 1855 $op->addParserOutputMetadata( $pOut ); 1856 $this->assertSame( '', $op->getHTML() ); 1857 1858 $op->addParserOutputText( $pOut ); 1859 $this->assertSame( '<some text>', $op->getHTML() ); 1860 } 1861 1862 /** 1863 * @covers OutputPage::addParserOutput 1864 */ 1865 public function testAddParserOutput() { 1866 $op = $this->newInstance(); 1867 $this->assertSame( '', $op->getHTML() ); 1868 $this->assertFalse( $op->showNewSectionLink() ); 1869 1870 $pOut = $this->createParserOutputStub( [ 1871 'getText' => '<some text>', 1872 'getNewSection' => true, 1873 ] ); 1874 1875 $op->addParserOutput( $pOut ); 1876 $this->assertSame( '<some text>', $op->getHTML() ); 1877 $this->assertTrue( $op->showNewSectionLink() ); 1878 } 1879 1880 /** 1881 * @covers OutputPage::addTemplate 1882 */ 1883 public function testAddTemplate() { 1884 $template = $this->createMock( QuickTemplate::class ); 1885 $template->method( 'getHTML' )->willReturn( '<abc>&def;' ); 1886 1887 $op = $this->newInstance(); 1888 $op->addTemplate( $template ); 1889 1890 $this->assertSame( '<abc>&def;', $op->getHTML() ); 1891 } 1892 1893 /** 1894 * @dataProvider provideParseAs 1895 * @covers OutputPage::parseAsContent 1896 */ 1897 public function testParseAsContent( 1898 array $args, $expectedHTML, $expectedHTMLInline = null 1899 ) { 1900 $op = $this->newInstance(); 1901 $this->assertSame( $expectedHTML, $op->parseAsContent( ...$args ) ); 1902 } 1903 1904 /** 1905 * @dataProvider provideParseAs 1906 * @covers OutputPage::parseAsInterface 1907 */ 1908 public function testParseAsInterface( 1909 array $args, $expectedHTML, $expectedHTMLInline = null 1910 ) { 1911 $op = $this->newInstance(); 1912 $this->assertSame( $expectedHTML, $op->parseAsInterface( ...$args ) ); 1913 } 1914 1915 /** 1916 * @dataProvider provideParseAs 1917 * @covers OutputPage::parseInlineAsInterface 1918 */ 1919 public function testParseInlineAsInterface( 1920 array $args, $expectedHTML, $expectedHTMLInline = null 1921 ) { 1922 $op = $this->newInstance(); 1923 $this->assertSame( 1924 $expectedHTMLInline ?? $expectedHTML, 1925 $op->parseInlineAsInterface( ...$args ) 1926 ); 1927 } 1928 1929 public function provideParseAs() { 1930 return [ 1931 'List at start of line' => [ 1932 [ '* List', true ], 1933 "<ul><li>List</li></ul>", 1934 ], 1935 'List not at start' => [ 1936 [ "* ''Not'' list", false ], 1937 '<p>* <i>Not</i> list</p>', 1938 '* <i>Not</i> list', 1939 ], 1940 'Italics' => [ 1941 [ "''Italic''", true ], 1942 "<p><i>Italic</i>\n</p>", 1943 '<i>Italic</i>', 1944 ], 1945 'formatnum' => [ 1946 [ '{{formatnum:123456.789}}', true ], 1947 "<p>123,456.789\n</p>", 1948 "123,456.789", 1949 ], 1950 'No section edit links' => [ 1951 [ '== Header ==' ], 1952 '<h2><span class="mw-headline" id="Header">Header</span></h2>', 1953 ] 1954 ]; 1955 } 1956 1957 /** 1958 * @covers OutputPage::parseAsContent 1959 */ 1960 public function testParseAsContentNullTitle() { 1961 $this->expectException( MWException::class ); 1962 $this->expectExceptionMessage( 'Empty $mTitle in OutputPage::parseInternal' ); 1963 $op = $this->newInstance( [], null, 'notitle' ); 1964 $op->parseAsContent( '' ); 1965 } 1966 1967 /** 1968 * @covers OutputPage::parseAsInterface 1969 */ 1970 public function testParseAsInterfaceNullTitle() { 1971 $this->expectException( MWException::class ); 1972 $this->expectExceptionMessage( 'Empty $mTitle in OutputPage::parseInternal' ); 1973 $op = $this->newInstance( [], null, 'notitle' ); 1974 $op->parseAsInterface( '' ); 1975 } 1976 1977 /** 1978 * @covers OutputPage::parseInlineAsInterface 1979 */ 1980 public function testParseInlineAsInterfaceNullTitle() { 1981 $this->expectException( MWException::class ); 1982 $this->expectExceptionMessage( 'Empty $mTitle in OutputPage::parseInternal' ); 1983 $op = $this->newInstance( [], null, 'notitle' ); 1984 $op->parseInlineAsInterface( '' ); 1985 } 1986 1987 /** 1988 * @covers OutputPage::setCdnMaxage 1989 * @covers OutputPage::lowerCdnMaxage 1990 */ 1991 public function testCdnMaxage() { 1992 $op = $this->newInstance(); 1993 $wrapper = TestingAccessWrapper::newFromObject( $op ); 1994 $this->assertSame( 0, $wrapper->mCdnMaxage ); 1995 1996 $op->setCdnMaxage( -1 ); 1997 $this->assertSame( -1, $wrapper->mCdnMaxage ); 1998 1999 $op->setCdnMaxage( 120 ); 2000 $this->assertSame( 120, $wrapper->mCdnMaxage ); 2001 2002 $op->setCdnMaxage( 60 ); 2003 $this->assertSame( 60, $wrapper->mCdnMaxage ); 2004 2005 $op->setCdnMaxage( 180 ); 2006 $this->assertSame( 180, $wrapper->mCdnMaxage ); 2007 2008 $op->lowerCdnMaxage( 240 ); 2009 $this->assertSame( 180, $wrapper->mCdnMaxage ); 2010 2011 $op->setCdnMaxage( 300 ); 2012 $this->assertSame( 240, $wrapper->mCdnMaxage ); 2013 2014 $op->lowerCdnMaxage( 120 ); 2015 $this->assertSame( 120, $wrapper->mCdnMaxage ); 2016 2017 $op->setCdnMaxage( 180 ); 2018 $this->assertSame( 120, $wrapper->mCdnMaxage ); 2019 2020 $op->setCdnMaxage( 60 ); 2021 $this->assertSame( 60, $wrapper->mCdnMaxage ); 2022 2023 $op->setCdnMaxage( 240 ); 2024 $this->assertSame( 120, $wrapper->mCdnMaxage ); 2025 } 2026 2027 /** @var int Faked time to set for tests that need it */ 2028 private static $fakeTime; 2029 2030 /** 2031 * @dataProvider provideAdaptCdnTTL 2032 * @covers OutputPage::adaptCdnTTL 2033 * @param array $args To pass to adaptCdnTTL() 2034 * @param int $expected Expected new value of mCdnMaxageLimit 2035 * @param array $options Associative array: 2036 * initialMaxage => Maxage to set before calling adaptCdnTTL() (default 86400) 2037 */ 2038 public function testAdaptCdnTTL( array $args, $expected, array $options = [] ) { 2039 try { 2040 MWTimestamp::setFakeTime( self::$fakeTime ); 2041 2042 $op = $this->newInstance(); 2043 // Set a high maxage so that it will get reduced by adaptCdnTTL(). The default maxage 2044 // is 0, so adaptCdnTTL() won't mutate the object at all. 2045 $initial = $options['initialMaxage'] ?? 86400; 2046 $op->setCdnMaxage( $initial ); 2047 2048 $op->adaptCdnTTL( ...$args ); 2049 } finally { 2050 MWTimestamp::setFakeTime( false ); 2051 } 2052 2053 $wrapper = TestingAccessWrapper::newFromObject( $op ); 2054 2055 // Special rules for false/null 2056 if ( $args[0] === null || $args[0] === false ) { 2057 $this->assertSame( $initial, $wrapper->mCdnMaxage, 'member value' ); 2058 $op->setCdnMaxage( $expected + 1 ); 2059 $this->assertSame( $expected + 1, $wrapper->mCdnMaxage, 'member value after new set' ); 2060 return; 2061 } 2062 2063 $this->assertSame( $expected, $wrapper->mCdnMaxageLimit, 'limit value' ); 2064 2065 if ( $initial >= $expected ) { 2066 $this->assertSame( $expected, $wrapper->mCdnMaxage, 'member value' ); 2067 } else { 2068 $this->assertSame( $initial, $wrapper->mCdnMaxage, 'member value' ); 2069 } 2070 2071 $op->setCdnMaxage( $expected + 1 ); 2072 $this->assertSame( $expected, $wrapper->mCdnMaxage, 'member value after new set' ); 2073 } 2074 2075 public function provideAdaptCdnTTL() { 2076 global $wgCdnMaxAge; 2077 $now = time(); 2078 self::$fakeTime = $now; 2079 return [ 2080 'Five minutes ago' => [ [ $now - 300 ], 270 ], 2081 'Now' => [ [ +0 ], IExpiringStore::TTL_MINUTE ], 2082 'Five minutes from now' => [ [ $now + 300 ], IExpiringStore::TTL_MINUTE ], 2083 'Five minutes ago, initial maxage four minutes' => 2084 [ [ $now - 300 ], 270, [ 'initialMaxage' => 240 ] ], 2085 'A very long time ago' => [ [ $now - 1000000000 ], $wgCdnMaxAge ], 2086 'Initial maxage zero' => [ [ $now - 300 ], 270, [ 'initialMaxage' => 0 ] ], 2087 2088 'false' => [ [ false ], IExpiringStore::TTL_MINUTE ], 2089 'null' => [ [ null ], IExpiringStore::TTL_MINUTE ], 2090 "'0'" => [ [ '0' ], IExpiringStore::TTL_MINUTE ], 2091 'Empty string' => [ [ '' ], IExpiringStore::TTL_MINUTE ], 2092 // @todo These give incorrect results due to timezones, how to test? 2093 //"'now'" => [ [ 'now' ], IExpiringStore::TTL_MINUTE ], 2094 //"'parse error'" => [ [ 'parse error' ], IExpiringStore::TTL_MINUTE ], 2095 2096 'Now, minTTL 0' => [ [ $now, 0 ], IExpiringStore::TTL_MINUTE ], 2097 'Now, minTTL 0.000001' => [ [ $now, 0.000001 ], 0 ], 2098 'A very long time ago, maxTTL even longer' => 2099 [ [ $now - 1000000000, 0, 1000000001 ], 900000000 ], 2100 ]; 2101 } 2102 2103 /** 2104 * @covers OutputPage::enableClientCache 2105 * @covers OutputPage::addParserOutputMetadata 2106 * @covers OutputPage::addParserOutput 2107 */ 2108 public function testClientCache() { 2109 $op = $this->newInstance(); 2110 2111 // Test initial value 2112 $this->assertSame( true, $op->enableClientCache( null ) ); 2113 // Test that calling with null doesn't change the value 2114 $this->assertSame( true, $op->enableClientCache( null ) ); 2115 2116 // Test setting to false 2117 $this->assertSame( true, $op->enableClientCache( false ) ); 2118 $this->assertSame( false, $op->enableClientCache( null ) ); 2119 // Test that calling with null doesn't change the value 2120 $this->assertSame( false, $op->enableClientCache( null ) ); 2121 2122 // Test that a cacheable ParserOutput doesn't set to true 2123 $pOutCacheable = $this->createParserOutputStub( 'isCacheable', true ); 2124 $op->addParserOutputMetadata( $pOutCacheable ); 2125 $this->assertSame( false, $op->enableClientCache( null ) ); 2126 2127 // Test setting back to true 2128 $this->assertSame( false, $op->enableClientCache( true ) ); 2129 $this->assertSame( true, $op->enableClientCache( null ) ); 2130 2131 // Test that an uncacheable ParserOutput does set to false 2132 $pOutUncacheable = $this->createParserOutputStub( 'isCacheable', false ); 2133 $op->addParserOutput( $pOutUncacheable ); 2134 $this->assertSame( false, $op->enableClientCache( null ) ); 2135 } 2136 2137 /** 2138 * @covers OutputPage::getCacheVaryCookies 2139 */ 2140 public function testGetCacheVaryCookies() { 2141 global $wgCookiePrefix, $wgDBname; 2142 $op = $this->newInstance(); 2143 $prefix = $wgCookiePrefix !== false ? $wgCookiePrefix : $wgDBname; 2144 $expectedCookies = [ 2145 "{$prefix}Token", 2146 "{$prefix}LoggedOut", 2147 "{$prefix}_session", 2148 'forceHTTPS', 2149 'cookie1', 2150 'cookie2', 2151 ]; 2152 2153 // We have to reset the cookies because getCacheVaryCookies may have already been called 2154 TestingAccessWrapper::newFromClass( OutputPage::class )->cacheVaryCookies = null; 2155 2156 $this->setMwGlobals( 'wgCacheVaryCookies', [ 'cookie1' ] ); 2157 $this->setTemporaryHook( 'GetCacheVaryCookies', 2158 function ( $innerOP, &$cookies ) use ( $op, $expectedCookies ) { 2159 $this->assertSame( $op, $innerOP ); 2160 $cookies[] = 'cookie2'; 2161 $this->assertSame( $expectedCookies, $cookies ); 2162 } 2163 ); 2164 2165 $this->assertSame( $expectedCookies, $op->getCacheVaryCookies() ); 2166 } 2167 2168 /** 2169 * @covers OutputPage::haveCacheVaryCookies 2170 */ 2171 public function testHaveCacheVaryCookies() { 2172 $request = new FauxRequest(); 2173 $op = $this->newInstance( [], $request ); 2174 2175 // No cookies are set. 2176 $this->assertFalse( $op->haveCacheVaryCookies() ); 2177 2178 // 'Token' is present but empty, so it shouldn't count. 2179 $request->setCookie( 'Token', '' ); 2180 $this->assertFalse( $op->haveCacheVaryCookies() ); 2181 2182 // 'Token' present and nonempty. 2183 $request->setCookie( 'Token', '123' ); 2184 $this->assertTrue( $op->haveCacheVaryCookies() ); 2185 } 2186 2187 /** 2188 * @dataProvider provideVaryHeaders 2189 * 2190 * @covers OutputPage::addVaryHeader 2191 * @covers OutputPage::getVaryHeader 2192 * 2193 * @param array[] $calls For each array, call addVaryHeader() with those arguments 2194 * @param string[] $cookies Array of cookie names to vary on 2195 * @param string $vary Text of expected Vary header (including the 'Vary: ') 2196 */ 2197 public function testVaryHeaders( array $calls, array $cookies, $vary ) { 2198 // Get rid of default Vary fields 2199 $op = $this->getMockBuilder( OutputPage::class ) 2200 ->setConstructorArgs( [ new RequestContext() ] ) 2201 ->setMethods( [ 'getCacheVaryCookies' ] ) 2202 ->getMock(); 2203 $op->expects( $this->any() ) 2204 ->method( 'getCacheVaryCookies' ) 2205 ->will( $this->returnValue( $cookies ) ); 2206 TestingAccessWrapper::newFromObject( $op )->mVaryHeader = []; 2207 2208 $this->filterDeprecated( '/The \$option parameter to addVaryHeader is ignored/' ); 2209 foreach ( $calls as $call ) { 2210 $op->addVaryHeader( ...$call ); 2211 } 2212 $this->assertEquals( $vary, $op->getVaryHeader(), 'Vary:' ); 2213 } 2214 2215 public function provideVaryHeaders() { 2216 return [ 2217 'No header' => [ 2218 [], 2219 [], 2220 'Vary: ', 2221 ], 2222 'Single header' => [ 2223 [ 2224 [ 'Cookie' ], 2225 ], 2226 [], 2227 'Vary: Cookie', 2228 ], 2229 'Non-unique headers' => [ 2230 [ 2231 [ 'Cookie' ], 2232 [ 'Accept-Language' ], 2233 [ 'Cookie' ], 2234 ], 2235 [], 2236 'Vary: Cookie, Accept-Language', 2237 ], 2238 'Two headers with single options' => [ 2239 // Options are deprecated since 1.34 2240 [ 2241 [ 'Cookie', [ 'param=phpsessid' ] ], 2242 [ 'Accept-Language', [ 'substr=en' ] ], 2243 ], 2244 [], 2245 'Vary: Cookie, Accept-Language', 2246 ], 2247 'One header with multiple options' => [ 2248 // Options are deprecated since 1.34 2249 [ 2250 [ 'Cookie', [ 'param=phpsessid', 'param=userId' ] ], 2251 ], 2252 [], 2253 'Vary: Cookie', 2254 ], 2255 'Duplicate option' => [ 2256 // Options are deprecated since 1.34 2257 [ 2258 [ 'Cookie', [ 'param=phpsessid' ] ], 2259 [ 'Cookie', [ 'param=phpsessid' ] ], 2260 [ 'Accept-Language', [ 'substr=en', 'substr=en' ] ], 2261 ], 2262 [], 2263 'Vary: Cookie, Accept-Language', 2264 ], 2265 'Same header, different options' => [ 2266 // Options are deprecated since 1.34 2267 [ 2268 [ 'Cookie', [ 'param=phpsessid' ] ], 2269 [ 'Cookie', [ 'param=userId' ] ], 2270 ], 2271 [], 2272 'Vary: Cookie', 2273 ], 2274 'No header, vary cookies' => [ 2275 [], 2276 [ 'cookie1', 'cookie2' ], 2277 'Vary: Cookie', 2278 ], 2279 'Cookie header with option plus vary cookies' => [ 2280 // Options are deprecated since 1.34 2281 [ 2282 [ 'Cookie', [ 'param=cookie1' ] ], 2283 ], 2284 [ 'cookie2', 'cookie3' ], 2285 'Vary: Cookie', 2286 ], 2287 'Non-cookie header plus vary cookies' => [ 2288 [ 2289 [ 'Accept-Language' ], 2290 ], 2291 [ 'cookie' ], 2292 'Vary: Accept-Language, Cookie', 2293 ], 2294 'Cookie and non-cookie headers plus vary cookies' => [ 2295 // Options are deprecated since 1.34 2296 [ 2297 [ 'Cookie', [ 'param=cookie1' ] ], 2298 [ 'Accept-Language' ], 2299 ], 2300 [ 'cookie2' ], 2301 'Vary: Cookie, Accept-Language', 2302 ], 2303 ]; 2304 } 2305 2306 /** 2307 * @covers OutputPage::getVaryHeader 2308 */ 2309 public function testVaryHeaderDefault() { 2310 $op = $this->newInstance(); 2311 $this->assertSame( 'Vary: Accept-Encoding, Cookie', $op->getVaryHeader() ); 2312 } 2313 2314 /** 2315 * @dataProvider provideLinkHeaders 2316 * 2317 * @covers OutputPage::addLinkHeader 2318 * @covers OutputPage::getLinkHeader 2319 */ 2320 public function testLinkHeaders( array $headers, $result ) { 2321 $op = $this->newInstance(); 2322 2323 foreach ( $headers as $header ) { 2324 $op->addLinkHeader( $header ); 2325 } 2326 2327 $this->assertEquals( $result, $op->getLinkHeader() ); 2328 } 2329 2330 public function provideLinkHeaders() { 2331 return [ 2332 [ 2333 [], 2334 false 2335 ], 2336 [ 2337 [ '<https://foo/bar.jpg>;rel=preload;as=image' ], 2338 'Link: <https://foo/bar.jpg>;rel=preload;as=image', 2339 ], 2340 [ 2341 [ 2342 '<https://foo/bar.jpg>;rel=preload;as=image', 2343 '<https://foo/baz.jpg>;rel=preload;as=image' 2344 ], 2345 'Link: <https://foo/bar.jpg>;rel=preload;as=image,<https://foo/baz.jpg>;' . 2346 'rel=preload;as=image', 2347 ], 2348 ]; 2349 } 2350 2351 /** 2352 * @dataProvider provideAddAcceptLanguage 2353 * @covers OutputPage::addAcceptLanguage 2354 */ 2355 public function testAddAcceptLanguage( 2356 $code, array $variants, $expected, array $options = [] 2357 ) { 2358 $req = new FauxRequest( in_array( 'varianturl', $options ) ? [ 'variant' => 'x' ] : [] ); 2359 $op = $this->newInstance( [], $req, in_array( 'notitle', $options ) ? 'notitle' : null ); 2360 2361 if ( !in_array( 'notitle', $options ) ) { 2362 $mockLang = $this->createMock( Language::class ); 2363 $mockLang->method( 'getCode' )->willReturn( $code ); 2364 2365 $mockLanguageConverter = $this 2366 ->createMock( ILanguageConverter::class ); 2367 if ( in_array( 'varianturl', $options ) ) { 2368 $mockLanguageConverter->expects( $this->never() )->method( $this->anything() ); 2369 } else { 2370 $mockLanguageConverter->method( 'hasVariants' )->willReturn( count( $variants ) > 1 ); 2371 $mockLanguageConverter->method( 'getVariants' )->willReturn( $variants ); 2372 } 2373 2374 $languageConverterFactory = $this 2375 ->createMock( LanguageConverterFactory::class ); 2376 $languageConverterFactory 2377 ->expects( $this->any() ) 2378 ->method( 'getLanguageConverter' ) 2379 ->willReturn( $mockLanguageConverter ); 2380 $this->setService( 2381 'LanguageConverterFactory', 2382 $languageConverterFactory 2383 ); 2384 2385 $mockTitle = $this->createMock( Title::class ); 2386 $mockTitle->method( 'getPageLanguage' )->willReturn( $mockLang ); 2387 2388 $op->setTitle( $mockTitle ); 2389 } 2390 2391 // This will run addAcceptLanguage() 2392 $op->sendCacheControl(); 2393 $this->assertSame( "Vary: $expected", $op->getVaryHeader() ); 2394 } 2395 2396 public function provideAddAcceptLanguage() { 2397 return [ 2398 'No variants' => [ 2399 'en', 2400 [ 'en' ], 2401 'Accept-Encoding, Cookie', 2402 ], 2403 'One simple variant' => [ 2404 'en', 2405 [ 'en', 'en-x-piglatin' ], 2406 'Accept-Encoding, Cookie, Accept-Language', 2407 ], 2408 'Multiple variants with BCP47 alternatives' => [ 2409 'zh', 2410 [ 'zh', 'zh-hans', 'zh-cn', 'zh-tw' ], 2411 'Accept-Encoding, Cookie, Accept-Language', 2412 ], 2413 'No title' => [ 2414 'en', 2415 [ 'en', 'en-x-piglatin' ], 2416 'Accept-Encoding, Cookie', 2417 [ 'notitle' ] 2418 ], 2419 'Variant in URL' => [ 2420 'en', 2421 [ 'en', 'en-x-piglatin' ], 2422 'Accept-Encoding, Cookie', 2423 [ 'varianturl' ] 2424 ], 2425 ]; 2426 } 2427 2428 /** 2429 * @covers OutputPage::preventClickjacking 2430 * @covers OutputPage::allowClickjacking 2431 * @covers OutputPage::getPreventClickjacking 2432 * @covers OutputPage::addParserOutputMetadata 2433 * @covers OutputPage::addParserOutput 2434 */ 2435 public function testClickjacking() { 2436 $op = $this->newInstance(); 2437 $this->assertTrue( $op->getPreventClickjacking() ); 2438 2439 $op->allowClickjacking(); 2440 $this->assertFalse( $op->getPreventClickjacking() ); 2441 2442 $op->preventClickjacking(); 2443 $this->assertTrue( $op->getPreventClickjacking() ); 2444 2445 $op->preventClickjacking( false ); 2446 $this->assertFalse( $op->getPreventClickjacking() ); 2447 2448 $pOut1 = $this->createParserOutputStub( 'preventClickjacking', true ); 2449 $op->addParserOutputMetadata( $pOut1 ); 2450 $this->assertTrue( $op->getPreventClickjacking() ); 2451 2452 // The ParserOutput can't allow, only prevent 2453 $pOut2 = $this->createParserOutputStub( 'preventClickjacking', false ); 2454 $op->addParserOutputMetadata( $pOut2 ); 2455 $this->assertTrue( $op->getPreventClickjacking() ); 2456 2457 // Reset to test with addParserOutput() 2458 $op->allowClickjacking(); 2459 $this->assertFalse( $op->getPreventClickjacking() ); 2460 2461 $op->addParserOutput( $pOut1 ); 2462 $this->assertTrue( $op->getPreventClickjacking() ); 2463 2464 $op->addParserOutput( $pOut2 ); 2465 $this->assertTrue( $op->getPreventClickjacking() ); 2466 } 2467 2468 /** 2469 * @dataProvider provideGetFrameOptions 2470 * @covers OutputPage::getFrameOptions 2471 * @covers OutputPage::preventClickjacking 2472 */ 2473 public function testGetFrameOptions( 2474 $breakFrames, $preventClickjacking, $editPageFrameOptions, $expected 2475 ) { 2476 $op = $this->newInstance( [ 2477 'BreakFrames' => $breakFrames, 2478 'EditPageFrameOptions' => $editPageFrameOptions, 2479 ] ); 2480 $op->preventClickjacking( $preventClickjacking ); 2481 2482 $this->assertSame( $expected, $op->getFrameOptions() ); 2483 } 2484 2485 public function provideGetFrameOptions() { 2486 return [ 2487 'BreakFrames true' => [ true, false, false, 'DENY' ], 2488 'Allow clickjacking locally' => [ false, false, 'DENY', false ], 2489 'Allow clickjacking globally' => [ false, true, false, false ], 2490 'DENY globally' => [ false, true, 'DENY', 'DENY' ], 2491 'SAMEORIGIN' => [ false, true, 'SAMEORIGIN', 'SAMEORIGIN' ], 2492 'BreakFrames with SAMEORIGIN' => [ true, true, 'SAMEORIGIN', 'DENY' ], 2493 ]; 2494 } 2495 2496 /** 2497 * See ResourceLoaderClientHtmlTest for full coverage. 2498 * 2499 * @dataProvider provideMakeResourceLoaderLink 2500 * 2501 * @covers OutputPage::makeResourceLoaderLink 2502 */ 2503 public function testMakeResourceLoaderLink( $args, $expectedHtml ) { 2504 $this->setMwGlobals( [ 2505 'wgResourceLoaderDebug' => false, 2506 'wgLoadScript' => 'http://127.0.0.1:8080/w/load.php', 2507 'wgCSPReportOnlyHeader' => true, 2508 ] ); 2509 $class = new ReflectionClass( OutputPage::class ); 2510 $method = $class->getMethod( 'makeResourceLoaderLink' ); 2511 $method->setAccessible( true ); 2512 $ctx = new RequestContext(); 2513 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory(); 2514 $ctx->setSkin( $skinFactory->makeSkin( 'fallback' ) ); 2515 $ctx->setLanguage( 'en' ); 2516 $out = new OutputPage( $ctx ); 2517 $reflectCSP = new ReflectionClass( ContentSecurityPolicy::class ); 2518 $nonce = $reflectCSP->getProperty( 'nonce' ); 2519 $nonce->setAccessible( true ); 2520 $nonce->setValue( $out->getCSP(), 'secret' ); 2521 $rl = $out->getResourceLoader(); 2522 $rl->setMessageBlobStore( $this->createMock( MessageBlobStore::class ) ); 2523 $rl->setDependencyStore( $this->createMock( KeyValueDependencyStore::class ) ); 2524 $rl->register( [ 2525 'test.foo' => [ 2526 'class' => ResourceLoaderTestModule::class, 2527 'script' => 'mw.test.foo( { a: true } );', 2528 'styles' => '.mw-test-foo { content: "style"; }', 2529 ], 2530 'test.bar' => [ 2531 'class' => ResourceLoaderTestModule::class, 2532 'script' => 'mw.test.bar( { a: true } );', 2533 'styles' => '.mw-test-bar { content: "style"; }', 2534 ], 2535 'test.baz' => [ 2536 'class' => ResourceLoaderTestModule::class, 2537 'script' => 'mw.test.baz( { a: true } );', 2538 'styles' => '.mw-test-baz { content: "style"; }', 2539 ], 2540 'test.quux' => [ 2541 'class' => ResourceLoaderTestModule::class, 2542 'script' => 'mw.test.baz( { token: 123 } );', 2543 'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }', 2544 'group' => 'private', 2545 ], 2546 'test.noscript' => [ 2547 'class' => ResourceLoaderTestModule::class, 2548 'styles' => '.stuff { color: red; }', 2549 'group' => 'noscript', 2550 ], 2551 'test.group.foo' => [ 2552 'class' => ResourceLoaderTestModule::class, 2553 'script' => 'mw.doStuff( "foo" );', 2554 'group' => 'foo', 2555 ], 2556 'test.group.bar' => [ 2557 'class' => ResourceLoaderTestModule::class, 2558 'script' => 'mw.doStuff( "bar" );', 2559 'group' => 'bar', 2560 ], 2561 ] ); 2562 $links = $method->invokeArgs( $out, $args ); 2563 $actualHtml = strval( $links ); 2564 $this->assertEquals( $expectedHtml, $actualHtml ); 2565 } 2566 2567 public static function provideMakeResourceLoaderLink() { 2568 // phpcs:disable Generic.Files.LineLength 2569 return [ 2570 // Single only=scripts load 2571 [ 2572 [ 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ], 2573 "<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){" 2574 . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.foo\u0026only=scripts");' 2575 . "});</script>" 2576 ], 2577 // Multiple only=styles load 2578 [ 2579 [ [ 'test.baz', 'test.foo', 'test.bar' ], ResourceLoaderModule::TYPE_STYLES ], 2580 2581 '<link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&modules=test.bar%2Cbaz%2Cfoo&only=styles"/>' 2582 ], 2583 // Private embed (only=scripts) 2584 [ 2585 [ 'test.quux', ResourceLoaderModule::TYPE_SCRIPTS ], 2586 "<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){" 2587 . "mw.test.baz({token:123});\nmw.loader.state({\"test.quux\":\"ready\"});" 2588 . "});</script>" 2589 ], 2590 // Load private module (combined) 2591 [ 2592 [ 'test.quux', ResourceLoaderModule::TYPE_COMBINED ], 2593 "<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){" 2594 . "mw.loader.implement(\"test.quux@1ev0i\",function($,jQuery,require,module){" 2595 . "mw.test.baz({token:123});},{\"css\":[\".mw-icon{transition:none}" 2596 . "\"]});});</script>" 2597 ], 2598 // Load no modules 2599 [ 2600 [ [], ResourceLoaderModule::TYPE_COMBINED ], 2601 '', 2602 ], 2603 // noscript group 2604 [ 2605 [ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ], 2606 '<noscript><link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&modules=test.noscript&only=styles"/></noscript>' 2607 ], 2608 // Load two modules in separate groups 2609 [ 2610 [ [ 'test.group.foo', 'test.group.bar' ], ResourceLoaderModule::TYPE_COMBINED ], 2611 "<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){" 2612 . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.bar");' 2613 . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.foo");' 2614 . "});</script>" 2615 ], 2616 ]; 2617 // phpcs:enable 2618 } 2619 2620 /** 2621 * @dataProvider provideBuildExemptModules 2622 * 2623 * @covers OutputPage::buildExemptModules 2624 */ 2625 public function testBuildExemptModules( array $exemptStyleModules, $expect ) { 2626 $this->setMwGlobals( [ 2627 'wgResourceLoaderDebug' => false, 2628 'wgLoadScript' => '/w/load.php', 2629 // Stub wgCacheEpoch as it influences getVersionHash used for the 2630 // urls in the expected HTML 2631 'wgCacheEpoch' => '20140101000000', 2632 ] ); 2633 2634 // Set up stubs 2635 $ctx = new RequestContext(); 2636 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory(); 2637 $ctx->setSkin( $skinFactory->makeSkin( 'fallback' ) ); 2638 $ctx->setLanguage( 'en' ); 2639 $op = $this->getMockBuilder( OutputPage::class ) 2640 ->setConstructorArgs( [ $ctx ] ) 2641 ->setMethods( [ 'buildCssLinksArray' ] ) 2642 ->getMock(); 2643 $op->method( 'buildCssLinksArray' ) 2644 ->willReturn( [] ); 2645 /** @var OutputPage $op */ 2646 $rl = $op->getResourceLoader(); 2647 $rl->setMessageBlobStore( $this->createMock( MessageBlobStore::class ) ); 2648 2649 // Register custom modules 2650 $rl->register( [ 2651 'example.site.a' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'site' ], 2652 'example.site.b' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'site' ], 2653 'example.user' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'user' ], 2654 ] ); 2655 2656 $op = TestingAccessWrapper::newFromObject( $op ); 2657 $op->rlExemptStyleModules = $exemptStyleModules; 2658 $expect = strtr( $expect, [ 2659 '{blankCombi}' => ResourceLoaderTestCase::BLANK_COMBI, 2660 ] ); 2661 $this->assertEquals( 2662 $expect, 2663 strval( $op->buildExemptModules() ) 2664 ); 2665 } 2666 2667 public static function provideBuildExemptModules() { 2668 // phpcs:disable Generic.Files.LineLength 2669 return [ 2670 'empty' => [ 2671 'exemptStyleModules' => [], 2672 '', 2673 ], 2674 'empty sets' => [ 2675 'exemptStyleModules' => [ 'site' => [], 'noscript' => [], 'private' => [], 'user' => [] ], 2676 '', 2677 ], 2678 'default logged-out' => [ 2679 'exemptStyleModules' => [ 'site' => [ 'site.styles' ] ], 2680 '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" . 2681 '<link rel="stylesheet" href="/w/load.php?lang=en&modules=site.styles&only=styles"/>', 2682 ], 2683 'default logged-in' => [ 2684 'exemptStyleModules' => [ 'site' => [ 'site.styles' ], 'user' => [ 'user.styles' ] ], 2685 '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" . 2686 '<link rel="stylesheet" href="/w/load.php?lang=en&modules=site.styles&only=styles"/>' . "\n" . 2687 '<link rel="stylesheet" href="/w/load.php?lang=en&modules=user.styles&only=styles&version=15pue"/>', 2688 ], 2689 'custom modules' => [ 2690 'exemptStyleModules' => [ 2691 'site' => [ 'site.styles', 'example.site.a', 'example.site.b' ], 2692 'user' => [ 'user.styles', 'example.user' ], 2693 ], 2694 '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" . 2695 '<link rel="stylesheet" href="/w/load.php?lang=en&modules=example.site.a%2Cb&only=styles"/>' . "\n" . 2696 '<link rel="stylesheet" href="/w/load.php?lang=en&modules=site.styles&only=styles"/>' . "\n" . 2697 '<link rel="stylesheet" href="/w/load.php?lang=en&modules=example.user&only=styles&version={blankCombi}"/>' . "\n" . 2698 '<link rel="stylesheet" href="/w/load.php?lang=en&modules=user.styles&only=styles&version=15pue"/>', 2699 ], 2700 ]; 2701 // phpcs:enable 2702 } 2703 2704 /** 2705 * @dataProvider provideTransformFilePath 2706 * @covers OutputPage::transformFilePath 2707 * @covers OutputPage::transformResourcePath 2708 */ 2709 public function testTransformResourcePath( $baseDir, $basePath, $uploadDir = null, 2710 $uploadPath = null, $path = null, $expected = null 2711 ) { 2712 if ( $path === null ) { 2713 // Skip optional $uploadDir and $uploadPath 2714 $path = $uploadDir; 2715 $expected = $uploadPath; 2716 $uploadDir = "$baseDir/images"; 2717 $uploadPath = "$basePath/images"; 2718 } 2719 $this->setMwGlobals( 'IP', $baseDir ); 2720 $conf = new HashConfig( [ 2721 'ResourceBasePath' => $basePath, 2722 'UploadDirectory' => $uploadDir, 2723 'UploadPath' => $uploadPath, 2724 ] ); 2725 2726 // Some of these paths don't exist and will cause warnings 2727 Wikimedia\suppressWarnings(); 2728 $actual = OutputPage::transformResourcePath( $conf, $path ); 2729 Wikimedia\restoreWarnings(); 2730 2731 $this->assertEquals( $expected ?: $path, $actual ); 2732 } 2733 2734 public static function provideTransformFilePath() { 2735 $baseDir = dirname( __DIR__ ) . '/data/media'; 2736 return [ 2737 // File that matches basePath, and exists. Hash found and appended. 2738 [ 2739 'baseDir' => $baseDir, 'basePath' => '/w', 2740 '/w/test.jpg', 2741 '/w/test.jpg?edcf2' 2742 ], 2743 // File that matches basePath, but not found on disk. Empty query. 2744 [ 2745 'baseDir' => $baseDir, 'basePath' => '/w', 2746 '/w/unknown.png', 2747 '/w/unknown.png?' 2748 ], 2749 // File not matching basePath. Ignored. 2750 [ 2751 'baseDir' => $baseDir, 'basePath' => '/w', 2752 '/files/test.jpg' 2753 ], 2754 // Empty string. Ignored. 2755 [ 2756 'baseDir' => $baseDir, 'basePath' => '/w', 2757 '', 2758 '' 2759 ], 2760 // Similar path, but with domain component. Ignored. 2761 [ 2762 'baseDir' => $baseDir, 'basePath' => '/w', 2763 '//example.org/w/test.jpg' 2764 ], 2765 [ 2766 'baseDir' => $baseDir, 'basePath' => '/w', 2767 'https://example.org/w/test.jpg' 2768 ], 2769 // Unrelated path with domain component. Ignored. 2770 [ 2771 'baseDir' => $baseDir, 'basePath' => '/w', 2772 'https://example.org/files/test.jpg' 2773 ], 2774 [ 2775 'baseDir' => $baseDir, 'basePath' => '/w', 2776 '//example.org/files/test.jpg' 2777 ], 2778 // Unrelated path with domain, and empty base path (root mw install). Ignored. 2779 [ 2780 'baseDir' => $baseDir, 'basePath' => '', 2781 'https://example.org/files/test.jpg' 2782 ], 2783 [ 2784 'baseDir' => $baseDir, 'basePath' => '', 2785 // T155310 2786 '//example.org/files/test.jpg' 2787 ], 2788 // Check UploadPath before ResourceBasePath (T155146) 2789 [ 2790 'baseDir' => dirname( $baseDir ), 'basePath' => '', 2791 'uploadDir' => $baseDir, 'uploadPath' => '/images', 2792 '/images/test.jpg', 2793 '/images/test.jpg?edcf2' 2794 ], 2795 ]; 2796 } 2797 2798 /** 2799 * Tests a particular case of transformCssMedia, using the given input, globals, 2800 * expected return, and message 2801 * 2802 * Asserts that $expectedReturn is returned. 2803 * 2804 * options['printableQuery'] - value of query string for printable, or omitted for none 2805 * options['handheldQuery'] - value of query string for handheld, or omitted for none 2806 * options['media'] - passed into the method under the same name 2807 * options['expectedReturn'] - expected return value 2808 * options['message'] - PHPUnit message for assertion 2809 * 2810 * @param array $args Key-value array of arguments as shown above 2811 */ 2812 protected function assertTransformCssMediaCase( $args ) { 2813 $queryData = []; 2814 if ( isset( $args['printableQuery'] ) ) { 2815 $queryData['printable'] = $args['printableQuery']; 2816 } 2817 2818 if ( isset( $args['handheldQuery'] ) ) { 2819 $queryData['handheld'] = $args['handheldQuery']; 2820 } 2821 2822 $fauxRequest = new FauxRequest( $queryData, false ); 2823 $this->setRequest( $fauxRequest ); 2824 2825 $actualReturn = OutputPage::transformCssMedia( $args['media'] ); 2826 $this->assertSame( $args['expectedReturn'], $actualReturn, $args['message'] ); 2827 } 2828 2829 /** 2830 * Tests print requests 2831 * 2832 * @covers OutputPage::transformCssMedia 2833 */ 2834 public function testPrintRequests() { 2835 $this->assertTransformCssMediaCase( [ 2836 'printableQuery' => '1', 2837 'media' => 'screen', 2838 'expectedReturn' => null, 2839 'message' => 'On printable request, screen returns null' 2840 ] ); 2841 2842 $this->assertTransformCssMediaCase( [ 2843 'printableQuery' => '1', 2844 'media' => self::SCREEN_MEDIA_QUERY, 2845 'expectedReturn' => null, 2846 'message' => 'On printable request, screen media query returns null' 2847 ] ); 2848 2849 $this->assertTransformCssMediaCase( [ 2850 'printableQuery' => '1', 2851 'media' => self::SCREEN_ONLY_MEDIA_QUERY, 2852 'expectedReturn' => null, 2853 'message' => 'On printable request, screen media query with only returns null' 2854 ] ); 2855 2856 $this->assertTransformCssMediaCase( [ 2857 'printableQuery' => '1', 2858 'media' => 'print', 2859 'expectedReturn' => '', 2860 'message' => 'On printable request, media print returns empty string' 2861 ] ); 2862 } 2863 2864 /** 2865 * Tests screen requests, without either query parameter set 2866 * 2867 * @covers OutputPage::transformCssMedia 2868 */ 2869 public function testScreenRequests() { 2870 $this->assertTransformCssMediaCase( [ 2871 'media' => 'screen', 2872 'expectedReturn' => 'screen', 2873 'message' => 'On screen request, screen media type is preserved' 2874 ] ); 2875 2876 $this->assertTransformCssMediaCase( [ 2877 'media' => 'handheld', 2878 'expectedReturn' => 'handheld', 2879 'message' => 'On screen request, handheld media type is preserved' 2880 ] ); 2881 2882 $this->assertTransformCssMediaCase( [ 2883 'media' => self::SCREEN_MEDIA_QUERY, 2884 'expectedReturn' => self::SCREEN_MEDIA_QUERY, 2885 'message' => 'On screen request, screen media query is preserved.' 2886 ] ); 2887 2888 $this->assertTransformCssMediaCase( [ 2889 'media' => self::SCREEN_ONLY_MEDIA_QUERY, 2890 'expectedReturn' => self::SCREEN_ONLY_MEDIA_QUERY, 2891 'message' => 'On screen request, screen media query with only is preserved.' 2892 ] ); 2893 2894 $this->assertTransformCssMediaCase( [ 2895 'media' => 'print', 2896 'expectedReturn' => 'print', 2897 'message' => 'On screen request, print media type is preserved' 2898 ] ); 2899 } 2900 2901 /** 2902 * Tests handheld behavior 2903 * 2904 * @covers OutputPage::transformCssMedia 2905 */ 2906 public function testHandheld() { 2907 $this->assertTransformCssMediaCase( [ 2908 'handheldQuery' => '1', 2909 'media' => 'handheld', 2910 'expectedReturn' => '', 2911 'message' => 'On request with handheld querystring and media is handheld, returns empty string' 2912 ] ); 2913 2914 $this->assertTransformCssMediaCase( [ 2915 'handheldQuery' => '1', 2916 'media' => 'screen', 2917 'expectedReturn' => null, 2918 'message' => 'On request with handheld querystring and media is screen, returns null' 2919 ] ); 2920 } 2921 2922 /** 2923 * @covers OutputPage::isTOCEnabled 2924 * @covers OutputPage::addParserOutputMetadata 2925 * @covers OutputPage::addParserOutput 2926 */ 2927 public function testIsTOCEnabled() { 2928 $op = $this->newInstance(); 2929 $this->assertFalse( $op->isTOCEnabled() ); 2930 2931 $pOut1 = $this->createParserOutputStub( 'getTOCHTML', false ); 2932 $op->addParserOutputMetadata( $pOut1 ); 2933 $this->assertFalse( $op->isTOCEnabled() ); 2934 2935 $pOut2 = $this->createParserOutputStub( 'getTOCHTML', true ); 2936 $op->addParserOutput( $pOut2 ); 2937 $this->assertTrue( $op->isTOCEnabled() ); 2938 2939 // The parser output doesn't disable the TOC after it was enabled 2940 $op->addParserOutputMetadata( $pOut1 ); 2941 $this->assertTrue( $op->isTOCEnabled() ); 2942 } 2943 2944 /** 2945 * @dataProvider providePreloadLinkHeaders 2946 * @covers ResourceLoaderSkinModule::getPreloadLinks 2947 * @covers ResourceLoaderSkinModule::getLogoPreloadlinks 2948 */ 2949 public function testPreloadLinkHeaders( $config, $result ) { 2950 $this->setMwGlobals( $config ); 2951 $ctx = $this->getMockBuilder( ResourceLoaderContext::class ) 2952 ->disableOriginalConstructor()->getMock(); 2953 $module = new ResourceLoaderSkinModule(); 2954 2955 $this->assertEquals( [ $result ], $module->getHeaders( $ctx ) ); 2956 } 2957 2958 public function providePreloadLinkHeaders() { 2959 return [ 2960 [ 2961 [ 2962 'wgResourceBasePath' => '/w', 2963 'wgLogo' => '/img/default.png', 2964 'wgLogos' => [ 2965 '1.5x' => '/img/one-point-five.png', 2966 '2x' => '/img/two-x.png', 2967 ], 2968 ], 2969 'Link: </img/default.png>;rel=preload;as=image;media=' . 2970 'not all and (min-resolution: 1.5dppx),' . 2971 '</img/one-point-five.png>;rel=preload;as=image;media=' . 2972 '(min-resolution: 1.5dppx) and (max-resolution: 1.999999dppx),' . 2973 '</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)' 2974 ], 2975 [ 2976 [ 2977 'wgResourceBasePath' => '/w', 2978 'wgLogos' => [ 2979 '1x' => '/img/default.png', 2980 ], 2981 ], 2982 'Link: </img/default.png>;rel=preload;as=image' 2983 ], 2984 [ 2985 [ 2986 'wgResourceBasePath' => '/w', 2987 'wgLogos' => [ 2988 '1x' => '/img/default.png', 2989 '2x' => '/img/two-x.png', 2990 ], 2991 ], 2992 'Link: </img/default.png>;rel=preload;as=image;media=' . 2993 'not all and (min-resolution: 2dppx),' . 2994 '</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)' 2995 ], 2996 [ 2997 [ 2998 'wgResourceBasePath' => '/w', 2999 'wgLogos' => [ 3000 '1x' => '/img/default.png', 3001 'svg' => '/img/vector.svg', 3002 ], 3003 ], 3004 'Link: </img/vector.svg>;rel=preload;as=image' 3005 3006 ], 3007 [ 3008 [ 3009 'wgResourceBasePath' => '/w', 3010 'wgLogos' => [ 3011 '1x' => '/w/test.jpg', 3012 ], 3013 'wgUploadPath' => '/w/images', 3014 'IP' => dirname( __DIR__ ) . '/data/media', 3015 ], 3016 'Link: </w/test.jpg?edcf2>;rel=preload;as=image', 3017 ], 3018 ]; 3019 } 3020 3021 /** 3022 * @param int $titleLastRevision Last Title revision to set 3023 * @param int $outputRevision Revision stored in OutputPage 3024 * @param bool $expectedResult Expected result of $output->isRevisionCurrent call 3025 * @covers OutputPage::isRevisionCurrent 3026 * @dataProvider provideIsRevisionCurrent 3027 */ 3028 public function testIsRevisionCurrent( $titleLastRevision, $outputRevision, $expectedResult ) { 3029 $titleMock = $this->createMock( Title::class ); 3030 $titleMock->expects( $this->any() ) 3031 ->method( 'getLatestRevID' ) 3032 ->willReturn( $titleLastRevision ); 3033 3034 $output = $this->newInstance( [], null ); 3035 $output->setTitle( $titleMock ); 3036 $output->setRevisionId( $outputRevision ); 3037 $this->assertEquals( $expectedResult, $output->isRevisionCurrent() ); 3038 } 3039 3040 public function provideIsRevisionCurrent() { 3041 return [ 3042 [ 10, null, true ], 3043 [ 42, 42, true ], 3044 [ null, 0, true ], 3045 [ 42, 47, false ], 3046 [ 47, 42, false ] 3047 ]; 3048 } 3049 3050 /** 3051 * @covers OutputPage::sendCacheControl 3052 * @dataProvider provideSendCacheControl 3053 */ 3054 public function testSendCacheControl( array $options = [], array $expectations = [] ) { 3055 $output = $this->newInstance( [ 3056 'LoggedOutMaxAge' => $options['loggedOutMaxAge'] ?? 0, 3057 'UseCdn' => $options['useCdn'] ?? false, 3058 ] ); 3059 3060 $output->enableClientCache( $options['enableClientCache'] ?? true ); 3061 $output->setCdnMaxage( $options['cdnMaxAge'] ?? 0 ); 3062 3063 if ( isset( $options['lastModified'] ) ) { 3064 $output->setLastModified( $options['lastModified'] ); 3065 } 3066 3067 $response = $output->getRequest()->response(); 3068 if ( isset( $options['cookie'] ) ) { 3069 $response->setCookie( 'test', 1234 ); 3070 } 3071 3072 $output->sendCacheControl(); 3073 3074 $headers = [ 3075 'Vary' => 'Accept-Encoding, Cookie', 3076 'Cache-Control' => 'private, must-revalidate, max-age=0', 3077 'Pragma' => false, 3078 'Expires' => true, 3079 'Last-Modified' => false, 3080 ]; 3081 3082 foreach ( $headers as $header => $default ) { 3083 $value = $expectations[$header] ?? $default; 3084 if ( $value === true ) { 3085 $this->assertNotEmpty( $response->getHeader( $header ) ); 3086 } elseif ( $value === false ) { 3087 $this->assertNull( $response->getHeader( $header ) ); 3088 } else { 3089 $this->assertEquals( $value, $response->getHeader( $header ) ); 3090 } 3091 } 3092 } 3093 3094 public function provideSendCacheControl() { 3095 return [ 3096 'Default' => [], 3097 'Logged out max-age' => [ 3098 [ 3099 'loggedOutMaxAge' => 300, 3100 ], 3101 [ 3102 'Cache-Control' => 'private, must-revalidate, max-age=300', 3103 ], 3104 ], 3105 'Cookies' => [ 3106 [ 3107 'cookie' => true, 3108 ], 3109 ], 3110 'Cookies with logged out max-age' => [ 3111 [ 3112 'loggedOutMaxAge' => 300, 3113 'cookie' => true, 3114 ], 3115 ], 3116 'Disable client cache' => [ 3117 [ 3118 'enableClientCache' => false, 3119 ], 3120 [ 3121 'Cache-Control' => 'no-cache, no-store, max-age=0, must-revalidate', 3122 'Pragma' => 'no-cache' 3123 ], 3124 ], 3125 'Set last modified' => [ 3126 [ 3127 // 0 is the current time, so we'll use 1 instead. 3128 'lastModified' => 1, 3129 ], 3130 [ 3131 'Last-Modified' => 'Thu, 01 Jan 1970 00:00:01 GMT', 3132 ] 3133 ], 3134 'Public' => [ 3135 [ 3136 'useCdn' => true, 3137 'cdnMaxAge' => 300, 3138 ], 3139 [ 3140 'Cache-Control' => 's-maxage=300, must-revalidate, max-age=0', 3141 'Expires' => false, 3142 ], 3143 ], 3144 ]; 3145 } 3146 3147 public function provideGetJsVarsEditable() { 3148 yield 'can edit and create' => [ 3149 'performer' => $this->mockAnonAuthorityWithPermissions( [ 'edit', 'create' ] ), 3150 'expectedEditableConfig' => [ 3151 'wgIsProbablyEditable' => true, 3152 'wgRelevantPageIsProbablyEditable' => true, 3153 ] 3154 ]; 3155 yield 'cannot edit or create' => [ 3156 'performer' => $this->mockAnonAuthorityWithoutPermissions( [ 'edit', 'create' ] ), 3157 'expectedEditableConfig' => [ 3158 'wgIsProbablyEditable' => false, 3159 'wgRelevantPageIsProbablyEditable' => false, 3160 ] 3161 ]; 3162 yield 'only can edit relevant title' => [ 3163 'performer' => $this->mockAnonAuthority( function ( 3164 string $permission, 3165 PageIdentity $page 3166 ) { 3167 if ( $permission === 'edit' | $permission === 'create' ) { 3168 if ( $page->getDBkey() === 'RelevantTitle' ) { 3169 return true; 3170 } 3171 return false; 3172 } 3173 return false; 3174 } ), 3175 'expectedEditableConfig' => [ 3176 'wgIsProbablyEditable' => false, 3177 'wgRelevantPageIsProbablyEditable' => true, 3178 ] 3179 ]; 3180 } 3181 3182 /** 3183 * @dataProvider provideGetJsVarsEditable 3184 * @covers OutputPage::performerCanEditOrCreate 3185 */ 3186 public function testGetJsVarsEditable( Authority $performer, array $expectedEditableConfig ) { 3187 $op = $this->newInstance( [], null, null, $performer ); 3188 $op->getContext()->getSkin()->setRelevantTitle( Title::newFromText( 'RelevantTitle' ) ); 3189 $this->assertArraySubmapSame( $expectedEditableConfig, $op->getJSVars() ); 3190 } 3191 3192 /** 3193 * @param bool $registered 3194 * @param bool $matchToken 3195 * @return MockObject|User 3196 */ 3197 private function mockUser( bool $registered, bool $matchToken ) { 3198 $user = $this->createNoOpMock( User::class, [ 'isRegistered', 'matchEditToken' ] ); 3199 $user->method( 'isRegistered' )->willReturn( $registered ); 3200 $user->method( 'matchEditToken' )->willReturn( $matchToken ); 3201 return $user; 3202 } 3203 3204 public function provideUserCanPreview() { 3205 yield 'all good' => [ 3206 'performer' => $this->mockUserAuthorityWithPermissions( 3207 $this->mockUser( true, true ), 3208 [ 'edit' ] 3209 ), 3210 'request' => new FauxRequest( [ 'action' => 'submit' ], true ), 3211 true 3212 ]; 3213 yield 'get request' => [ 3214 'performer' => $this->mockUserAuthorityWithPermissions( 3215 $this->mockUser( true, true ), 3216 [ 'edit' ] 3217 ), 3218 'request' => new FauxRequest( [ 'action' => 'submit' ], false ), 3219 false 3220 ]; 3221 yield 'not a submit action' => [ 3222 'performer' => $this->mockUserAuthorityWithPermissions( 3223 $this->mockUser( true, true ), 3224 [ 'edit' ] 3225 ), 3226 'request' => new FauxRequest( [ 'action' => 'something' ], true ), 3227 false 3228 ]; 3229 yield 'anon can not' => [ 3230 'performer' => $this->mockUserAuthorityWithPermissions( 3231 $this->mockUser( false, true ), 3232 [ 'edit' ] 3233 ), 3234 'request' => new FauxRequest( [ 'action' => 'submit' ], true ), 3235 false 3236 ]; 3237 yield 'token not match' => [ 3238 'performer' => $this->mockUserAuthorityWithPermissions( 3239 $this->mockUser( true, false ), 3240 [ 'edit' ] 3241 ), 3242 'request' => new FauxRequest( [ 'action' => 'submit' ], true ), 3243 false 3244 ]; 3245 yield 'no permission' => [ 3246 'performer' => $this->mockUserAuthorityWithoutPermissions( 3247 $this->mockUser( true, true ), 3248 [ 'edit' ] 3249 ), 3250 'request' => new FauxRequest( [ 'action' => 'submit' ], true ), 3251 false 3252 ]; 3253 } 3254 3255 /** 3256 * @dataProvider provideUserCanPreview 3257 * @covers OutputPage::userCanPreview 3258 */ 3259 public function testUserCanPreview( Authority $performer, WebRequest $request, bool $expected ) { 3260 $op = $this->newInstance( [], $request, null, $performer ); 3261 $this->assertSame( $expected, $op->userCanPreview() ); 3262 } 3263 3264 private function newInstance( 3265 array $config = [], 3266 WebRequest $request = null, 3267 $option = null, 3268 Authority $performer = null 3269 ) : OutputPage { 3270 $context = new RequestContext(); 3271 3272 $context->setConfig( new MultiConfig( [ 3273 new HashConfig( $config + [ 3274 'AppleTouchIcon' => false, 3275 'EnableCanonicalServerLink' => false, 3276 'Favicon' => false, 3277 'Feed' => false, 3278 'LanguageCode' => false, 3279 'ReferrerPolicy' => false, 3280 'RightsPage' => false, 3281 'RightsUrl' => false, 3282 'UniversalEditButton' => false, 3283 ] ), 3284 $context->getConfig() 3285 ] ) ); 3286 3287 if ( $option !== 'notitle' ) { 3288 $context->setTitle( Title::newFromText( 'My test page' ) ); 3289 } 3290 3291 if ( $request ) { 3292 $context->setRequest( $request ); 3293 } 3294 3295 if ( $performer ) { 3296 $context->setAuthority( $performer ); 3297 } 3298 3299 return new OutputPage( $context ); 3300 } 3301} 3302