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