1<?php
2
3use MediaWiki\MediaWikiServices;
4use MediaWiki\Revision\MutableRevisionRecord;
5use MediaWiki\Revision\RevisionRecord;
6use MediaWiki\Revision\SlotRecord;
7use PHPUnit\Framework\MockObject\MockObject;
8use Wikimedia\TestingAccessWrapper;
9
10/**
11 * @covers \Article::view()
12 */
13class ArticleViewTest extends MediaWikiIntegrationTestCase {
14
15	protected function setUp(): void {
16		parent::setUp();
17
18		$this->setUserLang( 'qqx' );
19	}
20
21	private function getHtml( OutputPage $output ) {
22		return preg_replace( '/<!--.*?-->/s', '', $output->getHTML() );
23	}
24
25	/**
26	 * @param string|Title $title
27	 * @param Content[]|string[] $revisionContents Content of the revisions to create
28	 *        (as Content or string).
29	 * @param RevisionRecord[] &$revisions will be filled with the RevisionRecord for $content.
30	 *
31	 * @return WikiPage
32	 * @throws MWException
33	 */
34	private function getPage( $title, array $revisionContents = [], array &$revisions = [] ) {
35		if ( is_string( $title ) ) {
36			$title = Title::makeTitle( $this->getDefaultWikitextNS(), $title );
37		}
38
39		$page = WikiPage::factory( $title );
40
41		$user = $this->getTestUser()->getUser();
42
43		// Make sure all revision have different timestamps all the time,
44		// to make timestamp asserts below deterministic.
45		$time = time() - 86400;
46		MWTimestamp::setFakeTime( $time );
47
48		foreach ( $revisionContents as $key => $cont ) {
49			if ( is_string( $cont ) ) {
50				$cont = new WikitextContent( $cont );
51			}
52
53			$u = $page->newPageUpdater( $user );
54			$u->setContent( SlotRecord::MAIN, $cont );
55			$rev = $u->saveRevision( CommentStoreComment::newUnsavedComment( 'Rev ' . $key ) );
56
57			$revisions[ $key ] = $rev;
58			MWTimestamp::setFakeTime( ++$time );
59		}
60		MWTimestamp::setFakeTime( false );
61
62		// Clear content model cache to support tests that mock the revision
63		$this->getServiceContainer()->getMainWANObjectCache()->clearProcessCache();
64
65		return $page;
66	}
67
68	/**
69	 * @covers Article::getOldId()
70	 * @covers Article::getRevIdFetched()
71	 */
72	public function testGetOldId() {
73		$revisions = [];
74		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
75
76		$idA = $revisions[1]->getId();
77		$idB = $revisions[2]->getId();
78
79		// oldid in constructor
80		$article = new Article( $page->getTitle(), $idA );
81		$this->assertSame( $idA, $article->getOldID() );
82		$article->fetchRevisionRecord();
83		$this->assertSame( $idA, $article->getRevIdFetched() );
84
85		// oldid 0 in constructor
86		$article = new Article( $page->getTitle(), 0 );
87		$this->assertSame( 0, $article->getOldID() );
88		$article->fetchRevisionRecord();
89		$this->assertSame( $idB, $article->getRevIdFetched() );
90
91		// oldid in request
92		$article = new Article( $page->getTitle() );
93		$context = new RequestContext();
94		$context->setRequest( new FauxRequest( [ 'oldid' => $idA ] ) );
95		$article->setContext( $context );
96		$this->assertSame( $idA, $article->getOldID() );
97		$article->fetchRevisionRecord();
98		$this->assertSame( $idA, $article->getRevIdFetched() );
99
100		// no oldid
101		$article = new Article( $page->getTitle() );
102		$context = new RequestContext();
103		$context->setRequest( new FauxRequest( [] ) );
104		$article->setContext( $context );
105		$this->assertSame( 0, $article->getOldID() );
106		$article->fetchRevisionRecord();
107		$this->assertSame( $idB, $article->getRevIdFetched() );
108	}
109
110	public function testView() {
111		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );
112
113		$article = new Article( $page->getTitle(), 0 );
114		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
115		$article->view();
116
117		$output = $article->getContext()->getOutput();
118		$this->assertStringContainsString( 'Test B', $this->getHtml( $output ) );
119		$this->assertStringNotContainsString( 'id="mw-revision-info"', $this->getHtml( $output ) );
120		$this->assertStringNotContainsString( 'id="mw-revision-nav"', $this->getHtml( $output ) );
121	}
122
123	public function testViewCached() {
124		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );
125
126		$po = new ParserOutput( 'Cached Text' );
127
128		$article = new Article( $page->getTitle(), 0 );
129		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
130
131		$cache = MediaWikiServices::getInstance()->getParserCache();
132		$cache->save( $po, $page, $article->getParserOptions() );
133
134		$article->view();
135
136		$output = $article->getContext()->getOutput();
137		$this->assertStringContainsString( 'Cached Text', $this->getHtml( $output ) );
138		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
139		$this->assertStringNotContainsString( 'Test B', $this->getHtml( $output ) );
140	}
141
142	/**
143	 * @covers Article::getPage()
144	 * @covers WikiPage::getRedirectTarget()
145	 */
146	public function testViewRedirect() {
147		$target = Title::makeTitle( $this->getDefaultWikitextNS(), 'Test_Target' );
148		$redirectText = '#REDIRECT [[' . $target->getPrefixedText() . ']]';
149
150		$page = $this->getPage( __METHOD__, [ $redirectText ] );
151
152		$article = new Article( $page->getTitle(), 0 );
153		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
154		$article->view();
155
156		$this->assertNotNull(
157			$article->getPage()->getRedirectTarget()->getPrefixedDBkey()
158		);
159		$this->assertSame(
160			$target->getPrefixedDBkey(),
161			$article->getPage()->getRedirectTarget()->getPrefixedDBkey()
162		);
163
164		$output = $article->getContext()->getOutput();
165		$this->assertStringContainsString( 'class="redirectText"', $this->getHtml( $output ) );
166		$this->assertStringContainsString(
167			'>' . htmlspecialchars( $target->getPrefixedText() ) . '<',
168			$this->getHtml( $output )
169		);
170	}
171
172	public function testViewNonText() {
173		$dummy = $this->getPage( __METHOD__, [ 'Dummy' ] );
174		$dummyRev = $dummy->getRevisionRecord();
175		$title = $dummy->getTitle();
176
177		/** @var MockObject|ContentHandler $mockHandler */
178		$mockHandler = $this->getMockBuilder( ContentHandler::class )
179			->onlyMethods(
180				[
181					'isParserCacheSupported',
182					'serializeContent',
183					'unserializeContent',
184					'makeEmptyContent',
185				]
186			)
187			->setConstructorArgs( [ 'NotText', [ 'application/frobnitz' ] ] )
188			->getMock();
189
190		$mockHandler->method( 'isParserCacheSupported' )
191			->willReturn( false );
192
193		$this->setTemporaryHook(
194			'ContentHandlerForModelID',
195			static function ( $id, &$handler ) use ( $mockHandler ) {
196				$handler = $mockHandler;
197			}
198		);
199
200		/** @var MockObject|Content $content */
201		$content = $this->createMock( Content::class );
202		$content->method( 'getParserOutput' )
203			->willReturn( new ParserOutput( 'Structured Output' ) );
204		$content->method( 'getModel' )
205			->willReturn( 'NotText' );
206		$content->expects( $this->never() )->method( 'getNativeData' );
207		$content->method( 'copy' )
208			->willReturn( $content );
209
210		$rev = new MutableRevisionRecord( $title );
211		$rev->setId( $dummyRev->getId() );
212		$rev->setPageId( $title->getArticleID() );
213		$rev->setUser( $dummyRev->getUser() );
214		$rev->setComment( $dummyRev->getComment() );
215		$rev->setTimestamp( $dummyRev->getTimestamp() );
216
217		$rev->setContent( SlotRecord::MAIN, $content );
218
219		/** @var MockObject|WikiPage $page */
220		$page = $this->getMockBuilder( WikiPage::class )
221			->onlyMethods( [ 'getRevisionRecord', 'getLatest' ] )
222			->setConstructorArgs( [ $title ] )
223			->getMock();
224
225		$page->method( 'getRevisionRecord' )
226			->willReturn( $rev );
227		$page->method( 'getLatest' )
228			->willReturn( $rev->getId() );
229
230		$article = Article::newFromWikiPage( $page, RequestContext::getMain() );
231		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
232		$article->view();
233
234		$output = $article->getContext()->getOutput();
235		$this->assertStringContainsString( 'Structured Output', $this->getHtml( $output ) );
236		$this->assertStringNotContainsString( 'Dummy', $this->getHtml( $output ) );
237	}
238
239	public function testViewOfOldRevision() {
240		$revisions = [];
241		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
242		$idA = $revisions[1]->getId();
243
244		$article = new Article( $page->getTitle(), $idA );
245		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
246		$article->view();
247
248		$output = $article->getContext()->getOutput();
249		$this->assertStringContainsString( 'Test A', $this->getHtml( $output ) );
250		$this->assertStringContainsString( 'id="mw-revision-info"', $output->getSubtitle() );
251		$this->assertStringContainsString( 'id="mw-revision-nav"', $output->getSubtitle() );
252
253		$this->assertStringNotContainsString( 'id="revision-info-current"', $output->getSubtitle() );
254		$this->assertStringNotContainsString( 'Test B', $this->getHtml( $output ) );
255		$this->assertSame( $idA, $output->getRevisionId() );
256		$this->assertSame( $revisions[1]->getTimestamp(), $output->getRevisionTimestamp() );
257	}
258
259	public function testViewOfOldRevisionFromCache() {
260		$this->setMwGlobals( [
261			'wgOldRevisionParserCacheExpireTime' => 100500,
262			'wgMainWANCache' => 'main',
263			'wgWANObjectCaches' => [
264				'main' => [
265					'class' => WANObjectCache::class,
266					'cacheId' => 'hash',
267				],
268			],
269		] );
270
271		$revisions = [];
272		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
273		$idA = $revisions[1]->getId();
274
275		// View the revision once (to get it into the cache)
276		$article = new Article( $page->getTitle(), $idA );
277		$article->view();
278
279		// Reset the output page and view the revision again (from ParserCache)
280		$article = new Article( $page->getTitle(), $idA );
281		$context = RequestContext::getMain();
282		$context->setOutput( new OutputPage( $context ) );
283		$article->setContext( $context );
284
285		$outputPageBeforeHTMLRevisionId = null;
286		$this->setTemporaryHook( 'OutputPageBeforeHTML',
287			static function ( OutputPage $out ) use ( &$outputPageBeforeHTMLRevisionId ) {
288				$outputPageBeforeHTMLRevisionId = $out->getRevisionId();
289			}
290		);
291
292		$article->view();
293		$output = $article->getContext()->getOutput();
294		$this->assertStringContainsString( 'Test A', $this->getHtml( $output ) );
295		$this->assertSame( 1, substr_count( $output->getSubtitle(), 'class="mw-revision warningbox"' ) );
296		$this->assertSame( $idA, $output->getRevisionId() );
297		$this->assertSame( $idA, $outputPageBeforeHTMLRevisionId );
298		$this->assertSame( $revisions[1]->getTimestamp(), $output->getRevisionTimestamp() );
299	}
300
301	public function testViewOfCurrentRevision() {
302		$revisions = [];
303		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
304		$idB = $revisions[2]->getId();
305
306		$article = new Article( $page->getTitle(), $idB );
307		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
308		$article->view();
309
310		$output = $article->getContext()->getOutput();
311		$this->assertStringContainsString( 'Test B', $this->getHtml( $output ) );
312		$this->assertStringContainsString( 'id="mw-revision-info-current"', $output->getSubtitle() );
313		$this->assertStringContainsString( 'id="mw-revision-nav"', $output->getSubtitle() );
314	}
315
316	public function testViewOfMissingRevision() {
317		$revisions = [];
318		$page = $this->getPage( __METHOD__, [ 1 => 'Test A' ], $revisions );
319		$badId = $revisions[1]->getId() + 100;
320
321		$article = new Article( $page->getTitle(), $badId );
322		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
323		$article->view();
324
325		$output = $article->getContext()->getOutput();
326		$this->assertStringContainsString( 'missing-revision: ' . $badId, $this->getHtml( $output ) );
327
328		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
329	}
330
331	public function testViewOfDeletedRevision() {
332		$revisions = [];
333		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
334		$idA = $revisions[1]->getId();
335
336		$revDelList = $this->getRevDelRevisionList( $page->getTitle(), $idA );
337		$revDelList->setVisibility( [
338			'value' => [ RevisionRecord::DELETED_TEXT => 1 ],
339			'comment' => "Testing",
340		] );
341
342		$article = new Article( $page->getTitle(), $idA );
343		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
344		$article->view();
345
346		$output = $article->getContext()->getOutput();
347		$this->assertStringContainsString( 'rev-deleted-text-permission', $this->getHtml( $output ) );
348
349		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
350		$this->assertStringNotContainsString( 'Test B', $this->getHtml( $output ) );
351	}
352
353	public function testUnhiddenViewOfDeletedRevision() {
354		$revisions = [];
355		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
356		$idA = $revisions[1]->getId();
357
358		$revDelList = $this->getRevDelRevisionList( $page->getTitle(), $idA );
359		$revDelList->setVisibility( [
360			'value' => [ RevisionRecord::DELETED_TEXT => 1 ],
361			'comment' => "Testing",
362		] );
363
364		$article = new Article( $page->getTitle(), $idA );
365		$context = new DerivativeContext( $article->getContext() );
366		$article->setContext( $context );
367		$context->getOutput()->setTitle( $page->getTitle() );
368		$context->getRequest()->setVal( 'unhide', 1 );
369		$context->setUser( $this->getTestUser( [ 'sysop' ] )->getUser() );
370		$article->view();
371
372		$output = $article->getContext()->getOutput();
373		$this->assertStringContainsString( 'rev-deleted-text-view', $this->getHtml( $output ) );
374
375		$this->assertStringContainsString( 'Test A', $this->getHtml( $output ) );
376		$this->assertStringNotContainsString( 'Test B', $this->getHtml( $output ) );
377	}
378
379	public function testViewMissingPage() {
380		$page = $this->getPage( __METHOD__ );
381
382		$article = new Article( $page->getTitle() );
383		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
384		$article->view();
385
386		$output = $article->getContext()->getOutput();
387		$this->assertStringContainsString( '(noarticletextanon)', $this->getHtml( $output ) );
388	}
389
390	public function testViewDeletedPage() {
391		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );
392		$page->doDeleteArticleReal( 'Test', $this->getTestSysop()->getUser() );
393
394		$article = new Article( $page->getTitle() );
395		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
396		$article->view();
397
398		$output = $article->getContext()->getOutput();
399		$this->assertStringContainsString( 'moveddeleted', $this->getHtml( $output ) );
400		$this->assertStringContainsString( 'logentry-delete-delete', $this->getHtml( $output ) );
401		$this->assertStringContainsString( '(noarticletextanon)', $this->getHtml( $output ) );
402
403		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
404		$this->assertStringNotContainsString( 'Test B', $this->getHtml( $output ) );
405	}
406
407	public function testViewMessagePage() {
408		$title = Title::makeTitle( NS_MEDIAWIKI, 'Mainpage' );
409		$page = $this->getPage( $title );
410
411		$article = new Article( $page->getTitle() );
412		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
413		$article->view();
414
415		$output = $article->getContext()->getOutput();
416		$this->assertStringContainsString(
417			wfMessage( 'mainpage' )->inContentLanguage()->parse(),
418			$this->getHtml( $output )
419		);
420		$this->assertStringNotContainsString( '(noarticletextanon)', $this->getHtml( $output ) );
421	}
422
423	public function testViewMissingUserPage() {
424		$user = $this->getTestUser()->getUser();
425		$user->addToDatabase();
426
427		$title = Title::makeTitle( NS_USER, $user->getName() );
428
429		$page = $this->getPage( $title );
430
431		$article = new Article( $page->getTitle() );
432		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
433		$article->view();
434
435		$output = $article->getContext()->getOutput();
436		$this->assertStringContainsString( '(noarticletextanon)', $this->getHtml( $output ) );
437		$this->assertStringNotContainsString(
438			'(userpage-userdoesnotexist-view)',
439			$this->getHtml( $output )
440		);
441	}
442
443	public function testViewUserPageOfNonexistingUser() {
444		$user = User::newFromName( 'Testing ' . __METHOD__ );
445
446		$title = Title::makeTitle( NS_USER, $user->getName() );
447
448		$page = $this->getPage( $title );
449
450		$article = new Article( $page->getTitle() );
451		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
452		$article->view();
453
454		$output = $article->getContext()->getOutput();
455		$this->assertStringContainsString( '(noarticletextanon)', $this->getHtml( $output ) );
456		$this->assertStringContainsString(
457			'(userpage-userdoesnotexist-view:',
458			$this->getHtml( $output )
459		);
460	}
461
462	public function testArticleViewHeaderHook() {
463		$page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );
464
465		$article = new Article( $page->getTitle(), 0 );
466		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
467
468		$this->setTemporaryHook(
469			'ArticleViewHeader',
470			function ( Article $articlePage, &$outputDone, &$useParserCache ) use ( $article ) {
471				$this->assertSame( $article, $articlePage, '$articlePage' );
472
473				$outputDone = new ParserOutput( 'Hook Text' );
474				$outputDone->setTitleText( 'Hook Title' );
475
476				$articlePage->getContext()->getOutput()->addParserOutput( $outputDone );
477			}
478		);
479
480		$article->view();
481
482		$output = $article->getContext()->getOutput();
483		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
484		$this->assertStringContainsString( 'Hook Text', $this->getHtml( $output ) );
485		$this->assertSame( 'Hook Title', $output->getPageTitle() );
486	}
487
488	public function testArticleRevisionViewCustomHook() {
489		$page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );
490
491		$article = new Article( $page->getTitle(), 0 );
492		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
493
494		// use ArticleViewHeader hook to bypass the parser cache
495		$this->setTemporaryHook(
496			'ArticleViewHeader',
497			static function ( Article $articlePage, &$outputDone, &$useParserCache ) {
498				$useParserCache = false;
499			}
500		);
501
502		$this->setTemporaryHook(
503			'ArticleRevisionViewCustom',
504			function ( RevisionRecord $rev, Title $title, $oldid, OutputPage $output ) use ( $page ) {
505				$content = $rev->getContent( SlotRecord::MAIN );
506				$this->assertSame( $page->getTitle(), $title, '$title' );
507				$this->assertSame( 'Test A', $content->getText(), '$content' );
508
509				$output->addHTML( 'Hook Text' );
510				return false;
511			}
512		);
513
514		$article->view();
515
516		$output = $article->getContext()->getOutput();
517		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
518		$this->assertStringContainsString( 'Hook Text', $this->getHtml( $output ) );
519	}
520
521	public function testShowMissingArticleHook() {
522		$page = $this->getPage( __METHOD__ );
523
524		$article = new Article( $page->getTitle() );
525		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
526
527		$this->setTemporaryHook(
528			'ShowMissingArticle',
529			function ( Article $articlePage ) use ( $article ) {
530				$this->assertSame( $article, $articlePage, '$articlePage' );
531
532				$articlePage->getContext()->getOutput()->addHTML( 'Hook Text' );
533			}
534		);
535
536		$article->view();
537
538		$output = $article->getContext()->getOutput();
539		$this->assertStringContainsString( '(noarticletextanon)', $this->getHtml( $output ) );
540		$this->assertStringContainsString( 'Hook Text', $this->getHtml( $output ) );
541	}
542
543	/**
544	 * @covers \Article::showViewError()
545	 */
546	public function testViewLatestError() {
547		$page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );
548
549		$article = new Article( $page->getTitle(), 0 );
550		$output = $article->getContext()->getOutput();
551		$output->setTitle( $page->getTitle() );
552
553		// use ArticleViewHeader hook to bypass the parser cache
554		$this->setTemporaryHook(
555			'ArticleViewHeader',
556			static function ( Article $articlePage, &$outputDone, &$useParserCache ) {
557				$useParserCache = false;
558			}
559		);
560
561		$article = TestingAccessWrapper::newFromObject( $article );
562		$article->fetchResult = Status::newFatal(
563			'rev-deleted-text-permission',
564			$page->getTitle()->getPrefixedDBkey()
565		);
566
567		$article->view();
568
569		$this->assertStringContainsString(
570			'rev-deleted-text-permission: ArticleViewTest::testViewLatestError',
571			$this->getHtml( $output )
572		);
573	}
574
575	/**
576	 * @covers \Article::showViewError()
577	 */
578	public function testViewOldError() {
579		$revisions = [];
580		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
581		$idA = $revisions[1]->getId();
582
583		$article = new Article( $page->getTitle(), $idA );
584		$output = $article->getContext()->getOutput();
585		$output->setTitle( $page->getTitle() );
586
587		$article = TestingAccessWrapper::newFromObject( $article );
588		$article->fetchResult = Status::newFatal(
589			'rev-deleted-text-permission',
590			$page->getTitle()->getPrefixedDBkey()
591		);
592
593		$article->view();
594
595		$this->assertStringContainsString(
596			'rev-deleted-text-permission: ArticleViewTest::testViewOldError',
597			$this->getHtml( $output )
598		);
599	}
600
601	private function getRevDelRevisionList( $title, $revisionId ) {
602		$services = MediaWikiServices::getInstance();
603		return new RevDelRevisionList(
604			RequestContext::getMain(),
605			$title,
606			[ $revisionId ],
607			$services->getDBLoadBalancerFactory(),
608			$services->getHookContainer(),
609			$services->getHtmlCacheUpdater(),
610			$services->getRevisionStore(),
611			$services->getMainWANObjectCache()
612		);
613	}
614}
615