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