1<?php
2
3// phpcs:disable MediaWiki.Commenting.PhpunitAnnotations.NotClass
4
5namespace MediaWiki\Tests\Revision;
6
7use CommentStoreComment;
8use LogicException;
9use MediaWiki\Revision\RevisionRecord;
10use MediaWiki\Revision\RevisionSlots;
11use MediaWiki\Revision\RevisionStoreRecord;
12use MediaWiki\Revision\SlotRecord;
13use MediaWiki\Revision\SuppressedDataException;
14use MediaWiki\User\UserIdentityValue;
15use TextContent;
16use Title;
17
18/**
19 * @covers \MediaWiki\Revision\RevisionRecord
20 *
21 * @note Expects to be used in classes that extend MediaWikiIntegrationTestCase.
22 */
23trait RevisionRecordTests {
24
25	/**
26	 * @param array $rowOverrides
27	 *
28	 * @return RevisionRecord
29	 */
30	abstract protected function newRevision( array $rowOverrides = [] );
31
32	private function provideAudienceCheckData( $field ) {
33		yield 'field accessible for oversighter (ALL)' => [
34			RevisionRecord::SUPPRESSED_ALL,
35			[ 'oversight' ],
36			true,
37			false
38		];
39
40		yield 'field accessible for oversighter' => [
41			RevisionRecord::DELETED_RESTRICTED | $field,
42			[ 'oversight' ],
43			true,
44			false
45		];
46
47		yield 'field not accessible for sysops (ALL)' => [
48			RevisionRecord::SUPPRESSED_ALL,
49			[ 'sysop' ],
50			false,
51			false
52		];
53
54		yield 'field not accessible for sysops' => [
55			RevisionRecord::DELETED_RESTRICTED | $field,
56			[ 'sysop' ],
57			false,
58			false
59		];
60
61		yield 'field accessible for sysops' => [
62			$field,
63			[ 'sysop' ],
64			true,
65			false
66		];
67
68		yield 'field suppressed for logged in users' => [
69			$field,
70			[ 'user' ],
71			false,
72			false
73		];
74
75		yield 'unrelated field suppressed' => [
76			$field === RevisionRecord::DELETED_COMMENT
77				? RevisionRecord::DELETED_USER
78				: RevisionRecord::DELETED_COMMENT,
79			[ 'user' ],
80			true,
81			true
82		];
83
84		yield 'nothing suppressed' => [
85			0,
86			[ 'user' ],
87			true,
88			true
89		];
90	}
91
92	public function testSerialization_fails() {
93		$this->expectException( LogicException::class );
94		$rev = $this->newRevision();
95		serialize( $rev );
96	}
97
98	public function provideGetComment_audience() {
99		return $this->provideAudienceCheckData( RevisionRecord::DELETED_COMMENT );
100	}
101
102	private function forceStandardPermissions() {
103		$this->setMwGlobals(
104			'wgGroupPermissions',
105			[
106				'user' => [
107					'viewsuppressed' => false,
108					'suppressrevision' => false,
109					'deletedtext' => false,
110					'deletedhistory' => false,
111				],
112				'sysop' => [
113					'viewsuppressed' => false,
114					'suppressrevision' => false,
115					'deletedtext' => true,
116					'deletedhistory' => true,
117				],
118				'oversight' => [
119					'deletedtext' => true,
120					'deletedhistory' => true,
121					'viewsuppressed' => true,
122					'suppressrevision' => true,
123				],
124			]
125		);
126	}
127
128	/**
129	 * @dataProvider provideGetComment_audience
130	 */
131	public function testGetComment_audience( $visibility, $groups, $userCan, $publicCan ) {
132		$this->forceStandardPermissions();
133
134		$user = $this->getTestUser( $groups )->getUser();
135		$rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
136
137		$this->assertNotNull( $rev->getComment( RevisionRecord::RAW ), 'raw can' );
138
139		$this->assertSame(
140			$publicCan,
141			$rev->getComment( RevisionRecord::FOR_PUBLIC ) !== null,
142			'public can'
143		);
144		$this->assertSame(
145			$userCan,
146			$rev->getComment( RevisionRecord::FOR_THIS_USER, $user ) !== null,
147			'user can'
148		);
149	}
150
151	public function provideGetUser_audience() {
152		return $this->provideAudienceCheckData( RevisionRecord::DELETED_USER );
153	}
154
155	/**
156	 * @dataProvider provideGetUser_audience
157	 */
158	public function testGetUser_audience( $visibility, $groups, $userCan, $publicCan ) {
159		$this->forceStandardPermissions();
160
161		$user = $this->getTestUser( $groups )->getUser();
162		$rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
163
164		$this->assertNotNull( $rev->getUser( RevisionRecord::RAW ), 'raw can' );
165
166		$this->assertSame(
167			$publicCan,
168			$rev->getUser( RevisionRecord::FOR_PUBLIC ) !== null,
169			'public can'
170		);
171		$this->assertSame(
172			$userCan,
173			$rev->getUser( RevisionRecord::FOR_THIS_USER, $user ) !== null,
174			'user can'
175		);
176	}
177
178	public function provideGetSlot_audience() {
179		return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT );
180	}
181
182	/**
183	 * @dataProvider provideGetSlot_audience
184	 */
185	public function testGetSlot_audience( $visibility, $groups, $userCan, $publicCan ) {
186		$this->forceStandardPermissions();
187
188		$user = $this->getTestUser( $groups )->getUser();
189		$rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
190
191		// NOTE: slot meta-data is never suppressed, just the content is!
192		$this->assertTrue( $rev->hasSlot( SlotRecord::MAIN ), 'hasSlot is never suppressed' );
193		$this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw meta' );
194		$this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ),
195			'public meta' );
196
197		$this->assertNotNull(
198			$rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ),
199			'user can'
200		);
201
202		try {
203			$rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC )->getContent();
204			$exception = null;
205		} catch ( SuppressedDataException $ex ) {
206			$exception = $ex;
207		}
208
209		$this->assertSame(
210			$publicCan,
211			$exception === null,
212			'public can'
213		);
214
215		try {
216			$rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user )->getContent();
217			$exception = null;
218		} catch ( SuppressedDataException $ex ) {
219			$exception = $ex;
220		}
221
222		$this->assertSame(
223			$userCan,
224			$exception === null,
225			'user can'
226		);
227	}
228
229	/**
230	 * @dataProvider provideGetSlot_audience
231	 */
232	public function testGetContent_audience( $visibility, $groups, $userCan, $publicCan ) {
233		$this->forceStandardPermissions();
234
235		$user = $this->getTestUser( $groups )->getUser();
236		$rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
237
238		$this->assertNotNull( $rev->getContent( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw can' );
239
240		$this->assertSame(
241			$publicCan,
242			$rev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ) !== null,
243			'public can'
244		);
245		$this->assertSame(
246			$userCan,
247			$rev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ) !== null,
248			'user can'
249		);
250	}
251
252	public function testGetSlot() {
253		$rev = $this->newRevision();
254
255		$slot = $rev->getSlot( SlotRecord::MAIN );
256		$this->assertNotNull( $slot, 'getSlot()' );
257		$this->assertSame( 'main', $slot->getRole(), 'getRole()' );
258	}
259
260	public function testHasSlot() {
261		$rev = $this->newRevision();
262
263		$this->assertTrue( $rev->hasSlot( SlotRecord::MAIN ) );
264		$this->assertFalse( $rev->hasSlot( 'xyz' ) );
265	}
266
267	public function testGetContent() {
268		$rev = $this->newRevision();
269
270		$content = $rev->getSlot( SlotRecord::MAIN );
271		$this->assertNotNull( $content, 'getContent()' );
272		$this->assertSame( CONTENT_MODEL_TEXT, $content->getModel(), 'getModel()' );
273	}
274
275	public function provideUserCanBitfield() {
276		yield [ 0, 0, [], null, true ];
277		// Bitfields match, user has no permissions
278		yield [
279			RevisionRecord::DELETED_TEXT,
280			RevisionRecord::DELETED_TEXT,
281			[],
282			null,
283			false
284		];
285		yield [
286			RevisionRecord::DELETED_COMMENT,
287			RevisionRecord::DELETED_COMMENT,
288			[],
289			null,
290			false,
291		];
292		yield [
293			RevisionRecord::DELETED_USER,
294			RevisionRecord::DELETED_USER,
295			[],
296			null,
297			false
298		];
299		yield [
300			RevisionRecord::DELETED_RESTRICTED,
301			RevisionRecord::DELETED_RESTRICTED,
302			[],
303			null,
304			false,
305		];
306		// Bitfields match, user (admin) does have permissions
307		yield [
308			RevisionRecord::DELETED_TEXT,
309			RevisionRecord::DELETED_TEXT,
310			[ 'sysop' ],
311			null,
312			true,
313		];
314		yield [
315			RevisionRecord::DELETED_COMMENT,
316			RevisionRecord::DELETED_COMMENT,
317			[ 'sysop' ],
318			null,
319			true,
320		];
321		yield [
322			RevisionRecord::DELETED_USER,
323			RevisionRecord::DELETED_USER,
324			[ 'sysop' ],
325			null,
326			true,
327		];
328		// Bitfields match, user (admin) does not have permissions
329		yield [
330			RevisionRecord::DELETED_RESTRICTED,
331			RevisionRecord::DELETED_RESTRICTED,
332			[ 'sysop' ],
333			null,
334			false,
335		];
336		// Bitfields match, user (oversight) does have permissions
337		yield [
338			RevisionRecord::DELETED_RESTRICTED,
339			RevisionRecord::DELETED_RESTRICTED,
340			[ 'oversight' ],
341			null,
342			true,
343		];
344		// Check permissions using the title
345		yield [
346			RevisionRecord::DELETED_TEXT,
347			RevisionRecord::DELETED_TEXT,
348			[ 'sysop' ],
349			__METHOD__,
350			true,
351		];
352		yield [
353			RevisionRecord::DELETED_TEXT,
354			RevisionRecord::DELETED_TEXT,
355			[],
356			__METHOD__,
357			false,
358		];
359	}
360
361	/**
362	 * @dataProvider provideUserCanBitfield
363	 * @covers \MediaWiki\Revision\RevisionRecord::userCanBitfield
364	 */
365	public function testUserCanBitfield( $bitField, $field, $userGroups, $title, $expected ) {
366		if ( is_string( $title ) ) {
367			// NOTE: Data providers cannot instantiate Title objects! See T202641.
368			$title = Title::newFromText( $title );
369		}
370
371		$this->forceStandardPermissions();
372
373		$user = $this->getTestUser( $userGroups )->getUser();
374
375		$this->assertSame(
376			$expected,
377			RevisionRecord::userCanBitfield( $bitField, $field, $user, $title )
378		);
379	}
380
381	public function provideHasSameContent() {
382		// Create some slots with content
383		$mainA = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'A' ) );
384		$mainB = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'B' ) );
385		$auxA = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) );
386		$auxB = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) );
387
388		$initialRecordSpec = [ [ $mainA ], 12 ];
389
390		return [
391			'same record object' => [
392				true,
393				$initialRecordSpec,
394				$initialRecordSpec,
395			],
396			'same record content, different object' => [
397				true,
398				[ [ $mainA ], 12 ],
399				[ [ $mainA ], 13 ],
400			],
401			'same record content, aux slot, different object' => [
402				true,
403				[ [ $auxA ], 12 ],
404				[ [ $auxB ], 13 ],
405			],
406			'different content' => [
407				false,
408				[ [ $mainA ], 12 ],
409				[ [ $mainB ], 13 ],
410			],
411			'different content and number of slots' => [
412				false,
413				[ [ $mainA ], 12 ],
414				[ [ $mainA, $mainB ], 13 ],
415			],
416		];
417	}
418
419	/**
420	 * @note Do not call directly from a data provider! Data providers cannot instantiate
421	 * Title objects! See T202641.
422	 *
423	 * @param SlotRecord[] $slots
424	 * @param int $revId
425	 * @return RevisionStoreRecord
426	 */
427	private function makeHasSameContentTestRecord( array $slots, $revId ) {
428		$title = Title::newFromText( 'provideHasSameContent' );
429		$title->resetArticleID( 19 );
430		$slots = new RevisionSlots( $slots );
431
432		return new RevisionStoreRecord(
433			$title,
434			new UserIdentityValue( 11, __METHOD__, 0 ),
435			CommentStoreComment::newUnsavedComment( __METHOD__ ),
436			(object)[
437				'rev_id' => strval( $revId ),
438				'rev_page' => strval( $title->getArticleID() ),
439				'rev_timestamp' => '20200101000000',
440				'rev_deleted' => 0,
441				'rev_minor_edit' => 0,
442				'rev_parent_id' => '5',
443				'rev_len' => $slots->computeSize(),
444				'rev_sha1' => $slots->computeSha1(),
445				'page_latest' => '18',
446			],
447			$slots
448		);
449	}
450
451	/**
452	 * @dataProvider provideHasSameContent
453	 * @covers \MediaWiki\Revision\RevisionRecord::hasSameContent
454	 * @group Database
455	 */
456	public function testHasSameContent(
457		$expected,
458		$recordSpec1,
459		$recordSpec2
460	) {
461		$record1 = $this->makeHasSameContentTestRecord( ...$recordSpec1 );
462		$record2 = $this->makeHasSameContentTestRecord( ...$recordSpec2 );
463
464		$this->assertSame(
465			$expected,
466			$record1->hasSameContent( $record2 )
467		);
468	}
469
470	public function provideIsDeleted() {
471		yield 'no deletion' => [
472			0,
473			[
474				RevisionRecord::DELETED_TEXT => false,
475				RevisionRecord::DELETED_COMMENT => false,
476				RevisionRecord::DELETED_USER => false,
477				RevisionRecord::DELETED_RESTRICTED => false,
478			]
479		];
480		yield 'text deleted' => [
481			RevisionRecord::DELETED_TEXT,
482			[
483				RevisionRecord::DELETED_TEXT => true,
484				RevisionRecord::DELETED_COMMENT => false,
485				RevisionRecord::DELETED_USER => false,
486				RevisionRecord::DELETED_RESTRICTED => false,
487			]
488		];
489		yield 'text and comment deleted' => [
490			RevisionRecord::DELETED_TEXT + RevisionRecord::DELETED_COMMENT,
491			[
492				RevisionRecord::DELETED_TEXT => true,
493				RevisionRecord::DELETED_COMMENT => true,
494				RevisionRecord::DELETED_USER => false,
495				RevisionRecord::DELETED_RESTRICTED => false,
496			]
497		];
498		yield 'all 4 deleted' => [
499			RevisionRecord::DELETED_TEXT +
500			RevisionRecord::DELETED_COMMENT +
501			RevisionRecord::DELETED_RESTRICTED +
502			RevisionRecord::DELETED_USER,
503			[
504				RevisionRecord::DELETED_TEXT => true,
505				RevisionRecord::DELETED_COMMENT => true,
506				RevisionRecord::DELETED_USER => true,
507				RevisionRecord::DELETED_RESTRICTED => true,
508			]
509		];
510	}
511
512	/**
513	 * @dataProvider provideIsDeleted
514	 * @covers \MediaWiki\Revision\RevisionRecord::isDeleted
515	 */
516	public function testIsDeleted( $revDeleted, $assertionMap ) {
517		$rev = $this->newRevision( [ 'rev_deleted' => $revDeleted ] );
518		foreach ( $assertionMap as $deletionLevel => $expected ) {
519			$this->assertSame( $expected, $rev->isDeleted( $deletionLevel ) );
520		}
521	}
522
523	public function testIsReadyForInsertion() {
524		$rev = $this->newRevision();
525		$this->assertTrue( $rev->isReadyForInsertion() );
526	}
527
528}
529