Revert "Remove usages and hard deprecate User::changeable(By)Group"
[mediawiki.git] / tests / phpunit / includes / user / UserTest.php
blob2b20de8aac10c46563a3cceb27e2b7ac9eea0fdf
1 <?php
3 use MediaWiki\Block\CompositeBlock;
4 use MediaWiki\Block\DatabaseBlock;
5 use MediaWiki\Block\Restriction\NamespaceRestriction;
6 use MediaWiki\Block\Restriction\PageRestriction;
7 use MediaWiki\Block\SystemBlock;
8 use MediaWiki\Interwiki\ClassicInterwikiLookup;
9 use MediaWiki\MediaWikiServices;
10 use MediaWiki\User\UserIdentityValue;
11 use Wikimedia\TestingAccessWrapper;
13 /**
14 * @group Database
16 class UserTest extends MediaWikiIntegrationTestCase {
18 /** Constant for self::testIsBlockedFrom */
19 private const USER_TALK_PAGE = '<user talk page>';
21 /**
22 * @var User
24 protected $user;
26 protected function setUp() : void {
27 parent::setUp();
29 $this->setMwGlobals( [
30 'wgGroupPermissions' => [],
31 'wgRevokePermissions' => [],
32 'wgUseRCPatrol' => true,
33 'wgWatchlistExpiry' => true,
34 'wgAutoConfirmAge' => 0,
35 'wgAutoConfirmCount' => 0,
36 ] );
38 $this->setUpPermissionGlobals();
40 $this->user = $this->getTestUser( 'unittesters' )->getUser();
43 private function setUpPermissionGlobals() {
44 global $wgGroupPermissions, $wgRevokePermissions;
46 # Data for regular $wgGroupPermissions test
47 $wgGroupPermissions['unittesters'] = [
48 'test' => true,
49 'runtest' => true,
50 'writetest' => false,
51 'nukeworld' => false,
52 'autoconfirmed' => false,
54 $wgGroupPermissions['testwriters'] = [
55 'test' => true,
56 'writetest' => true,
57 'modifytest' => true,
58 'autoconfirmed' => true,
61 # Data for regular $wgRevokePermissions test
62 $wgRevokePermissions['formertesters'] = [
63 'runtest' => true,
66 # For the options and watchlist tests
67 $wgGroupPermissions['*'] = [
68 'editmyoptions' => true,
69 'editmywatchlist' => true,
70 'viewmywatchlist' => true,
73 # For patrol tests
74 $wgGroupPermissions['patroller'] = [
75 'patrol' => true,
78 # For account creation when blocked test
79 $wgGroupPermissions['accountcreator'] = [
80 'createaccount' => true,
81 'ipblock-exempt' => true
84 # For bot and ratelimit tests
85 $wgGroupPermissions['bot'] = [
86 'bot' => true,
87 'noratelimit' => true,
91 private function setSessionUser( User $user, WebRequest $request ) {
92 RequestContext::getMain()->setUser( $user );
93 RequestContext::getMain()->setRequest( $request );
94 TestingAccessWrapper::newFromObject( $user )->mRequest = $request;
95 $request->getSession()->setUser( $user );
98 /**
99 * @covers User::getGroupPermissions
101 public function testGroupPermissions() {
102 $rights = User::getGroupPermissions( [ 'unittesters' ] );
103 $this->assertContains( 'runtest', $rights );
104 $this->assertNotContains( 'writetest', $rights );
105 $this->assertNotContains( 'modifytest', $rights );
106 $this->assertNotContains( 'nukeworld', $rights );
108 $rights = User::getGroupPermissions( [ 'unittesters', 'testwriters' ] );
109 $this->assertContains( 'runtest', $rights );
110 $this->assertContains( 'writetest', $rights );
111 $this->assertContains( 'modifytest', $rights );
112 $this->assertNotContains( 'nukeworld', $rights );
116 * @covers User::getGroupPermissions
118 public function testRevokePermissions() {
119 $rights = User::getGroupPermissions( [ 'unittesters', 'formertesters' ] );
120 $this->assertNotContains( 'runtest', $rights );
121 $this->assertNotContains( 'writetest', $rights );
122 $this->assertNotContains( 'modifytest', $rights );
123 $this->assertNotContains( 'nukeworld', $rights );
127 * TODO: Remove. This is the same as PermissionManagerTest::testGetUserPermissions
128 * @covers User::getRights
130 public function testUserPermissions() {
131 $rights = $this->user->getRights();
132 $this->assertContains( 'runtest', $rights );
133 $this->assertNotContains( 'writetest', $rights );
134 $this->assertNotContains( 'modifytest', $rights );
135 $this->assertNotContains( 'nukeworld', $rights );
139 * TODO: Remove. This is the same as PermissionManagerTest::testGetUserPermissionsHooks
140 * @covers User::getRights
142 public function testUserGetRightsHooks() {
143 $user = $this->getTestUser( [ 'unittesters', 'testwriters' ] )->getUser();
144 $userWrapper = TestingAccessWrapper::newFromObject( $user );
146 $rights = $user->getRights();
147 $this->assertContains( 'test', $rights, 'sanity check' );
148 $this->assertContains( 'runtest', $rights, 'sanity check' );
149 $this->assertContains( 'writetest', $rights, 'sanity check' );
150 $this->assertNotContains( 'nukeworld', $rights, 'sanity check' );
152 // Add a hook manipulating the rights
153 $this->setTemporaryHook( 'UserGetRights', function ( $user, &$rights ) {
154 $rights[] = 'nukeworld';
155 $rights = array_diff( $rights, [ 'writetest' ] );
156 } );
158 MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache( $user );
159 $rights = $user->getRights();
160 $this->assertContains( 'test', $rights );
161 $this->assertContains( 'runtest', $rights );
162 $this->assertNotContains( 'writetest', $rights );
163 $this->assertContains( 'nukeworld', $rights );
165 // Add a Session that limits rights
166 $mock = $this->getMockBuilder( stdClass::class )
167 ->setMethods( [ 'getAllowedUserRights', 'deregisterSession', 'getSessionId' ] )
168 ->getMock();
169 $mock->method( 'getAllowedUserRights' )->willReturn( [ 'test', 'writetest' ] );
170 $mock->method( 'getSessionId' )->willReturn(
171 new MediaWiki\Session\SessionId( str_repeat( 'X', 32 ) )
173 $session = MediaWiki\Session\TestUtils::getDummySession( $mock );
174 $mockRequest = $this->getMockBuilder( FauxRequest::class )
175 ->setMethods( [ 'getSession' ] )
176 ->getMock();
177 $mockRequest->method( 'getSession' )->willReturn( $session );
178 $userWrapper->mRequest = $mockRequest;
180 $this->resetServices();
181 $rights = $user->getRights();
182 $this->assertContains( 'test', $rights );
183 $this->assertNotContains( 'runtest', $rights );
184 $this->assertNotContains( 'writetest', $rights );
185 $this->assertNotContains( 'nukeworld', $rights );
189 * @dataProvider provideGetGroupsWithPermission
190 * @covers User::getGroupsWithPermission
192 public function testGetGroupsWithPermission( array $expected, $right ) {
193 $result = User::getGroupsWithPermission( $right );
194 $this->assertArrayEquals( $expected, $result );
197 public static function provideGetGroupsWithPermission() {
198 return [
200 [ 'unittesters', 'testwriters' ],
201 'test'
204 [ 'unittesters' ],
205 'runtest'
208 [ 'testwriters' ],
209 'writetest'
212 [ 'testwriters' ],
213 'modifytest'
219 * @covers User::isAllowedAny
220 * @covers User::isAllowedAll
221 * @covers User::isAllowed
222 * @covers User::isNewbie
224 public function testIsAllowed() {
225 $this->assertFalse(
226 $this->user->isAllowed( 'writetest' ),
227 'Basic isAllowed works with a group not granted a right'
229 $this->assertTrue(
230 $this->user->isAllowedAny( 'test', 'writetest' ),
231 'A user with only one of the rights can pass isAllowedAll'
233 $this->assertTrue(
234 $this->user->isAllowedAll( 'test', 'runtest' ),
235 'A user with multiple rights can pass isAllowedAll'
237 $this->assertFalse(
238 $this->user->isAllowedAll( 'test', 'runtest', 'writetest' ),
239 'A user needs all rights specified to pass isAllowedAll'
241 $this->assertTrue(
242 $this->user->isNewbie(),
243 'Unit testers are not autoconfirmed yet'
246 $user = $this->getTestUser( 'testwriters' )->getUser();
247 $this->assertTrue(
248 $user->isAllowed( 'test' ),
249 'Basic isAllowed works with a group granted a right'
251 $this->assertTrue(
252 $user->isAllowed( 'writetest' ),
253 'Testwriters pass isAllowed with `writetest`'
255 $this->assertFalse(
256 $user->isNewbie(),
257 'Test writers are autoconfirmed'
262 * @covers User::useRCPatrol
263 * @covers User::useNPPatrol
264 * @covers User::useFilePatrol
266 public function testPatrolling() {
267 $user = $this->getTestUser( 'patroller' )->getUser();
269 $this->assertTrue( $user->useRCPatrol() );
270 $this->assertTrue( $user->useNPPatrol() );
271 $this->assertTrue( $user->useFilePatrol() );
273 $this->assertFalse( $this->user->useRCPatrol() );
274 $this->assertFalse( $this->user->useNPPatrol() );
275 $this->assertFalse( $this->user->useFilePatrol() );
279 * @covers User::getGroups
280 * @covers User::getGroupMemberships
281 * @covers User::isBot
283 public function testBot() {
284 $user = $this->getTestUser( 'bot' )->getUser();
286 $this->assertSame( $user->getGroups(), [ 'bot' ] );
287 $this->assertArrayHasKey( 'bot', $user->getGroupMemberships() );
288 $this->assertTrue( $user->isBot() );
290 $this->assertArrayNotHasKey( 'bot', $this->user->getGroupMemberships() );
291 $this->assertFalse( $this->user->isBot() );
295 * @dataProvider provideIPs
296 * @covers User::isIP
298 public function testIsIP( $value, $result, $message ) {
299 $this->assertSame( $result, $this->user->isIP( $value ), $message );
302 public static function provideIPs() {
303 return [
304 [ '', false, 'Empty string' ],
305 [ ' ', false, 'Blank space' ],
306 [ '10.0.0.0', true, 'IPv4 private 10/8' ],
307 [ '10.255.255.255', true, 'IPv4 private 10/8' ],
308 [ '192.168.1.1', true, 'IPv4 private 192.168/16' ],
309 [ '203.0.113.0', true, 'IPv4 example' ],
310 [ '2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff', true, 'IPv6 example' ],
311 // Not valid IPs but classified as such by MediaWiki for negated asserting
312 // of whether this might be the identifier of a logged-out user or whether
313 // to allow usernames like it.
314 [ '300.300.300.300', true, 'Looks too much like an IPv4 address' ],
315 [ '203.0.113.xxx', true, 'Assigned by UseMod to cloaked logged-out users' ],
320 * @dataProvider provideUserNames
321 * @covers User::isValidUserName
323 public function testIsValidUserName( $username, $result, $message ) {
324 $this->assertSame( $result, $this->user->isValidUserName( $username ), $message );
327 public static function provideUserNames() {
328 return [
329 [ '', false, 'Empty string' ],
330 [ ' ', false, 'Blank space' ],
331 [ 'abcd', false, 'Starts with small letter' ],
332 [ 'Ab/cd', false, 'Contains slash' ],
333 [ 'Ab cd', true, 'Whitespace' ],
334 [ '192.168.1.1', false, 'IP' ],
335 [ '116.17.184.5/32', false, 'IP range' ],
336 [ '::e:f:2001/96', false, 'IPv6 range' ],
337 [ 'User:Abcd', false, 'Reserved Namespace' ],
338 [ '12abcd232', true, 'Starts with Numbers' ],
339 [ '?abcd', true, 'Start with ? mark' ],
340 [ '#abcd', false, 'Start with #' ],
341 [ 'Abcdകഖഗഘ', true, ' Mixed scripts' ],
342 [ 'ജോസ്‌തോമസ്', false, 'ZWNJ- Format control character' ],
343 [ 'Ab cd', false, ' Ideographic space' ],
344 [ '300.300.300.300', false, 'Looks too much like an IPv4 address' ],
345 [ '302.113.311.900', false, 'Looks too much like an IPv4 address' ],
346 [ '203.0.113.xxx', false, 'Reserved for usage by UseMod for cloaked logged-out users' ],
351 * Test User::editCount
352 * @group medium
353 * @covers User::getEditCount
354 * @covers User::setEditCountInternal
356 public function testGetEditCount() {
357 $user = $this->getMutableTestUser()->getUser();
359 // let the user have a few (3) edits
360 $page = WikiPage::factory( Title::makeTitle( NS_HELP, 'UserTest_EditCount' ) );
361 for ( $i = 0; $i < 3; $i++ ) {
362 $page->doEditContent(
363 ContentHandler::makeContent( (string)$i, $page->getTitle() ),
364 'test',
366 false,
367 $user
371 $this->assertSame(
373 $user->getEditCount(),
374 'After three edits, the user edit count should be 3'
377 // increase the edit count
378 $user->incEditCount();
379 $user->clearInstanceCache();
381 $this->assertSame(
383 $user->getEditCount(),
384 'After increasing the edit count manually, the user edit count should be 4'
387 // Update the edit count
388 $user->setEditCountInternal( 42 );
389 $this->assertSame(
391 $user->getEditCount(),
392 'After setting the edit count manually, the user edit count should be 42'
397 * Test User::editCount
398 * @group medium
399 * @covers User::getEditCount
400 * @covers User::incEditCount
402 public function testGetEditCountForAnons() {
403 $user = User::newFromName( 'Anonymous' );
405 $this->assertNull(
406 $user->getEditCount(),
407 'Edit count starts null for anonymous users.'
410 $this->assertNull(
411 $user->incEditCount(),
412 'Edit count cannot be increased for anonymous users'
415 $this->assertNull(
416 $user->getEditCount(),
417 'Edit count remains null for anonymous users despite calls to increase it.'
422 * Test User::editCount
423 * @group medium
424 * @covers User::incEditCount
426 public function testIncEditCount() {
427 $user = $this->getMutableTestUser()->getUser();
428 $user->incEditCount();
430 $reloadedUser = User::newFromId( $user->getId() );
431 $reloadedUser->incEditCount();
433 $this->assertSame(
435 $reloadedUser->getEditCount(),
436 'Increasing the edit count after a fresh load leaves the object up to date.'
441 * Test changing user options.
442 * @covers User::setOption
443 * @covers User::getOptions
444 * @covers User::getBoolOption
445 * @covers User::getIntOption
446 * @covers User::getStubThreshold
448 public function testOptions() {
449 $this->setMwGlobals( [
450 'wgMaxArticleSize' => 2,
451 ] );
452 $user = $this->getMutableTestUser()->getUser();
454 $user->setOption( 'userjs-someoption', 'test' );
455 $user->setOption( 'userjs-someintoption', '42' );
456 $user->setOption( 'rclimit', 200 );
457 $user->setOption( 'wpwatchlistdays', '0' );
458 $user->setOption( 'stubthreshold', 1024 );
459 $user->setOption( 'userjs-usedefaultoverride', '' );
460 $user->saveSettings();
462 MediaWikiServices::getInstance()->getUserOptionsManager()->clearUserOptionsCache( $user );
463 $this->assertSame( 'test', $user->getOption( 'userjs-someoption' ) );
464 $this->assertTrue( $user->getBoolOption( 'userjs-someoption' ) );
465 $this->assertEquals( 200, $user->getOption( 'rclimit' ) );
466 $this->assertSame( 42, $user->getIntOption( 'userjs-someintoption' ) );
467 $this->assertSame(
468 123,
469 $user->getIntOption( 'userjs-usedefaultoverride', 123 ),
470 'Int options that are empty string can have a default returned'
472 $this->assertSame(
473 1024,
474 $user->getStubThreshold(),
475 'Valid stub threshold preferences are respected'
478 MediaWikiServices::getInstance()->getUserOptionsManager()->clearUserOptionsCache( $user );
479 MediaWikiServices::getInstance()->getMainWANObjectCache()->clearProcessCache();
480 $this->assertSame( 'test', $user->getOption( 'userjs-someoption' ) );
481 $this->assertTrue( $user->getBoolOption( 'userjs-someoption' ) );
482 $this->assertEquals( 200, $user->getOption( 'rclimit' ) );
483 $this->assertSame( 42, $user->getIntOption( 'userjs-someintoption' ) );
484 $this->assertSame(
486 $user->getIntOption( 'userjs-usedefaultoverride' ),
487 'Int options that are empty string and have no default specified default to 0'
489 $this->assertSame(
490 1024,
491 $user->getStubThreshold(),
492 'Valid stub threshold preferences are respected after cache is cleared'
495 // Check that an option saved as a string '0' is returned as an integer.
496 MediaWikiServices::getInstance()->getUserOptionsManager()->clearUserOptionsCache( $user );
497 $this->assertSame( 0, $user->getOption( 'wpwatchlistdays' ) );
498 $this->assertFalse( $user->getBoolOption( 'wpwatchlistdays' ) );
500 // Check that getStubThreashold resorts to 0 if invalid
501 $user->setOption( 'stubthreshold', 4096 );
502 $user->saveSettings();
503 $this->assertSame(
505 $user->getStubThreshold(),
506 'If a stub threashold is impossible, it defaults to 0'
511 * T39963
512 * Make sure defaults are loaded when setOption is called.
513 * @covers User::setOption
515 public function testAnonOptions() {
516 global $wgDefaultUserOptions;
517 $this->user->setOption( 'userjs-someoption', 'test' );
518 $this->assertSame( $wgDefaultUserOptions['rclimit'], $this->user->getOption( 'rclimit' ) );
519 $this->assertSame( 'test', $this->user->getOption( 'userjs-someoption' ) );
523 * Test password validity checks. There are 3 checks in core,
524 * - ensure the password meets the minimal length
525 * - ensure the password is not the same as the username
526 * - ensure the username/password combo isn't forbidden
527 * @covers User::checkPasswordValidity()
528 * @covers User::isValidPassword()
530 public function testCheckPasswordValidity() {
531 $this->setMwGlobals( [
532 'wgPasswordPolicy' => [
533 'policies' => [
534 'sysop' => [
535 'MinimalPasswordLength' => 8,
536 'MinimumPasswordLengthToLogin' => 1,
537 'PasswordCannotMatchUsername' => 1,
538 'PasswordCannotBeSubstringInUsername' => 1,
540 'default' => [
541 'MinimalPasswordLength' => 6,
542 'PasswordCannotMatchUsername' => true,
543 'PasswordCannotBeSubstringInUsername' => true,
544 'PasswordCannotMatchDefaults' => true,
545 'MaximalPasswordLength' => 40,
548 'checks' => [
549 'MinimalPasswordLength' => 'PasswordPolicyChecks::checkMinimalPasswordLength',
550 'MinimumPasswordLengthToLogin' => 'PasswordPolicyChecks::checkMinimumPasswordLengthToLogin',
551 'PasswordCannotMatchUsername' => 'PasswordPolicyChecks::checkPasswordCannotMatchUsername',
552 'PasswordCannotBeSubstringInUsername' =>
553 'PasswordPolicyChecks::checkPasswordCannotBeSubstringInUsername',
554 'PasswordCannotMatchDefaults' => 'PasswordPolicyChecks::checkPasswordCannotMatchDefaults',
555 'MaximalPasswordLength' => 'PasswordPolicyChecks::checkMaximalPasswordLength',
558 ] );
560 // Sanity
561 $this->assertTrue( $this->user->isValidPassword( 'Password1234' ) );
563 // Minimum length
564 $this->assertFalse( $this->user->isValidPassword( 'a' ) );
565 $this->assertFalse( $this->user->checkPasswordValidity( 'a' )->isGood() );
566 $this->assertTrue( $this->user->checkPasswordValidity( 'a' )->isOK() );
568 // Maximum length
569 $longPass = str_repeat( 'a', 41 );
570 $this->assertFalse( $this->user->isValidPassword( $longPass ) );
571 $this->assertFalse( $this->user->checkPasswordValidity( $longPass )->isGood() );
572 $this->assertFalse( $this->user->checkPasswordValidity( $longPass )->isOK() );
574 // Matches username
575 $this->assertFalse( $this->user->checkPasswordValidity( $this->user->getName() )->isGood() );
576 $this->assertTrue( $this->user->checkPasswordValidity( $this->user->getName() )->isOK() );
578 $this->setTemporaryHook( 'isValidPassword', function ( $password, &$result, $user ) {
579 $result = 'isValidPassword returned false';
580 return false;
581 } );
582 $status = $this->user->checkPasswordValidity( 'Password1234' );
583 $this->assertTrue( $status->isOK() );
584 $this->assertFalse( $status->isGood() );
585 $this->assertSame( $status->getErrors()[0]['message'], 'isValidPassword returned false' );
587 $this->removeTemporaryHook( 'isValidPassword' );
589 $this->setTemporaryHook( 'isValidPassword', function ( $password, &$result, $user ) {
590 $result = true;
591 return true;
592 } );
593 $status = $this->user->checkPasswordValidity( 'Password1234' );
594 $this->assertTrue( $status->isOK() );
595 $this->assertTrue( $status->isGood() );
596 $this->assertSame( [], $status->getErrors() );
598 $this->removeTemporaryHook( 'isValidPassword' );
600 $this->setTemporaryHook( 'isValidPassword', function ( $password, &$result, $user ) {
601 $result = 'isValidPassword returned true';
602 return true;
603 } );
604 $status = $this->user->checkPasswordValidity( 'Password1234' );
605 $this->assertTrue( $status->isOK() );
606 $this->assertFalse( $status->isGood() );
607 $this->assertSame( $status->getErrors()[0]['message'], 'isValidPassword returned true' );
609 $this->removeTemporaryHook( 'isValidPassword' );
611 // On the forbidden list
612 $user = User::newFromName( 'Useruser' );
613 $this->assertFalse( $user->checkPasswordValidity( 'Passpass' )->isGood() );
617 * @covers User::getCanonicalName()
618 * @dataProvider provideGetCanonicalName
620 public function testGetCanonicalName( $name, array $expectedArray ) {
621 // fake interwiki map for the 'Interwiki prefix' testcase
622 $this->setMwGlobals( [
623 'wgInterwikiCache' => ClassicInterwikiLookup::buildCdbHash( [
625 'iw_prefix' => 'interwiki',
626 'iw_url' => 'http://example.com/',
627 'iw_local' => 0,
628 'iw_trans' => 0,
630 ] ),
631 ] );
633 foreach ( $expectedArray as $validate => $expected ) {
634 $this->assertSame(
635 $expected,
636 User::getCanonicalName( $name, $validate === 'false' ? false : $validate ),
637 $validate
642 public static function provideGetCanonicalName() {
643 return [
644 'Leading space' => [ ' Leading space', [ 'creatable' => 'Leading space' ] ],
645 'Trailing space ' => [ 'Trailing space ', [ 'creatable' => 'Trailing space' ] ],
646 'Namespace prefix' => [ 'Talk:Username', [ 'creatable' => false, 'usable' => false,
647 'valid' => false, 'false' => 'Talk:Username' ] ],
648 'Interwiki prefix' => [ 'interwiki:Username', [ 'creatable' => false, 'usable' => false,
649 'valid' => false, 'false' => 'Interwiki:Username' ] ],
650 'With hash' => [ 'name with # hash', [ 'creatable' => false, 'usable' => false ] ],
651 'Multi spaces' => [ 'Multi spaces', [ 'creatable' => 'Multi spaces',
652 'usable' => 'Multi spaces' ] ],
653 'Lowercase' => [ 'lowercase', [ 'creatable' => 'Lowercase' ] ],
654 'Invalid character' => [ 'in[]valid', [ 'creatable' => false, 'usable' => false,
655 'valid' => false, 'false' => 'In[]valid' ] ],
656 'With slash' => [ 'with / slash', [ 'creatable' => false, 'usable' => false, 'valid' => false,
657 'false' => 'With / slash' ] ],
662 * @covers User::getCanonicalName()
664 public function testGetCanonicalName_bad() {
665 $this->expectException( InvalidArgumentException::class );
666 $this->expectExceptionMessage(
667 'Invalid parameter value for validation'
669 User::getCanonicalName( 'ValidName', 'InvalidValidationValue' );
673 * @covers User::equals
675 public function testEquals() {
676 $first = $this->getMutableTestUser()->getUser();
677 $second = User::newFromName( $first->getName() );
679 $this->assertTrue( $first->equals( $first ) );
680 $this->assertTrue( $first->equals( $second ) );
681 $this->assertTrue( $second->equals( $first ) );
683 $third = $this->getMutableTestUser()->getUser();
684 $fourth = $this->getMutableTestUser()->getUser();
686 $this->assertFalse( $third->equals( $fourth ) );
687 $this->assertFalse( $fourth->equals( $third ) );
689 // Test users loaded from db with id
690 $user = $this->getMutableTestUser()->getUser();
691 $fifth = User::newFromId( $user->getId() );
692 $sixth = User::newFromName( $user->getName() );
693 $this->assertTrue( $fifth->equals( $sixth ) );
697 * @covers User::getId
698 * @covers User::setId
700 public function testUserId() {
701 $this->assertGreaterThan( 0, $this->user->getId() );
703 $user = User::newFromName( 'UserWithNoId' );
704 $this->assertSame( 0, $user->getId() );
706 $user->setId( 7 );
707 $this->assertSame(
709 $user->getId(),
710 'Manually setting a user id via ::setId is reflected in ::getId'
713 $user = new User;
714 $user->setName( '1.2.3.4' );
715 $this->assertSame(
717 $user->getId(),
718 'IPs have an id of 0'
723 * @covers User::isRegistered
724 * @covers User::isLoggedIn
725 * @covers User::isAnon
726 * @covers User::logOut
728 public function testIsRegistered() {
729 $user = $this->getMutableTestUser()->getUser();
730 $this->assertTrue( $user->isRegistered() );
731 $this->assertTrue( $user->isLoggedIn() ); // Deprecated wrapper method
732 $this->assertFalse( $user->isAnon() );
734 $this->setTemporaryHook( 'UserLogout', function ( &$user ) {
735 return false;
736 } );
737 $user->logout();
738 $this->assertTrue( $user->isRegistered() );
740 $this->removeTemporaryHook( 'UserLogout' );
741 $user->logout();
742 $this->assertFalse( $user->isRegistered() );
744 // Non-existent users are perceived as anonymous
745 $user = User::newFromName( 'UTNonexistent' );
746 $this->assertFalse( $user->isRegistered() );
747 $this->assertFalse( $user->isLoggedIn() ); // Deprecated wrapper method
748 $this->assertTrue( $user->isAnon() );
750 $user = new User;
751 $this->assertFalse( $user->isRegistered() );
752 $this->assertFalse( $user->isLoggedIn() ); // Deprecated wrapper method
753 $this->assertTrue( $user->isAnon() );
757 * @covers User::setRealName
758 * @covers User::getRealName
760 public function testRealName() {
761 $user = $this->getMutableTestUser()->getUser();
762 $realName = 'John Doe';
764 $user->setRealName( $realName );
765 $this->assertSame(
766 $realName,
767 $user->getRealName(),
768 'Real name retrieved from cache'
771 $id = $user->getId();
772 $user->saveSettings();
774 $otherUser = User::newFromId( $id );
775 $this->assertSame(
776 $realName,
777 $user->getRealName(),
778 'Real name retrieved from database'
783 * @covers User::checkAndSetTouched
784 * @covers User::getDBTouched()
786 public function testCheckAndSetTouched() {
787 $user = $this->getMutableTestUser()->getUser();
788 $user = TestingAccessWrapper::newFromObject( $user );
789 $this->assertTrue( $user->isRegistered() );
791 $touched = $user->getDBTouched();
792 $this->assertTrue(
793 $user->checkAndSetTouched(), "checkAndSetTouched() succedeed" );
794 $this->assertGreaterThan(
795 $touched, $user->getDBTouched(), "user_touched increased with casOnTouched()" );
797 $touched = $user->getDBTouched();
798 $this->assertTrue(
799 $user->checkAndSetTouched(), "checkAndSetTouched() succedeed #2" );
800 $this->assertGreaterThan(
801 $touched, $user->getDBTouched(), "user_touched increased with casOnTouched() #2" );
805 * @covers User::validateCache
806 * @covers User::getTouched
808 public function testValidateCache() {
809 $user = $this->getTestUser()->getUser();
811 $initialTouchMW = $user->getTouched();
812 $initialTouchUnix = ( new MWTimestamp( $initialTouchMW ) )->getTimestamp();
814 $earlierUnix = $initialTouchUnix - 1000;
815 $earlierMW = ( new MWTimestamp( $earlierUnix ) )->getTimestamp( TS_MW );
816 $this->assertFalse(
817 $user->validateCache( $earlierMW ),
818 'Caches from before the value of getTouched() are not valid'
821 $laterUnix = $initialTouchUnix + 1000;
822 $laterMW = ( new MWTimestamp( $laterUnix ) )->getTimestamp( TS_MW );
823 $this->assertTrue(
824 $user->validateCache( $laterMW ),
825 'Caches from after the value of getTouched() are valid'
830 * @covers User::findUsersByGroup
832 public function testFindUsersByGroup() {
833 // FIXME: fails under postgres
834 $this->markTestSkippedIfDbType( 'postgres' );
836 $users = User::findUsersByGroup( [] );
837 $this->assertSame( 0, iterator_count( $users ) );
839 $users = User::findUsersByGroup( 'foo', 1, 1 );
840 $this->assertSame( 0, iterator_count( $users ) );
842 $user = $this->getMutableTestUser( [ 'foo' ] )->getUser();
843 $users = User::findUsersByGroup( 'foo' );
844 $this->assertSame( 1, iterator_count( $users ) );
845 $users->rewind();
846 $this->assertTrue( $user->equals( $users->current() ) );
848 // arguments have OR relationship
849 $user2 = $this->getMutableTestUser( [ 'bar' ] )->getUser();
850 $users = User::findUsersByGroup( [ 'foo', 'bar' ] );
851 $this->assertSame( 2, iterator_count( $users ) );
852 $users->rewind();
853 $this->assertTrue( $user->equals( $users->current() ) );
854 $users->next();
855 $this->assertTrue( $user2->equals( $users->current() ) );
857 // users are not duplicated
858 $user = $this->getMutableTestUser( [ 'baz', 'boom' ] )->getUser();
859 $users = User::findUsersByGroup( [ 'baz', 'boom' ] );
860 $this->assertSame( 1, iterator_count( $users ) );
861 $users->rewind();
862 $this->assertTrue( $user->equals( $users->current() ) );
866 * @covers User::getBlockedStatus
868 public function testSoftBlockRanges() {
869 $this->setMwGlobals( 'wgSoftBlockRanges', [ '10.0.0.0/8' ] );
871 // IP isn't in $wgSoftBlockRanges
872 $user = new User();
873 $request = new FauxRequest();
874 $request->setIP( '192.168.0.1' );
875 $this->setSessionUser( $user, $request );
876 $this->assertNull( $user->getBlock() );
878 // IP is in $wgSoftBlockRanges
879 $user = new User();
880 $request = new FauxRequest();
881 $request->setIP( '10.20.30.40' );
882 $this->setSessionUser( $user, $request );
883 $block = $user->getBlock();
884 $this->assertInstanceOf( SystemBlock::class, $block );
885 $this->assertSame( 'wgSoftBlockRanges', $block->getSystemBlockType() );
887 // Make sure the block is really soft
888 $request = new FauxRequest();
889 $request->setIP( '10.20.30.40' );
890 $this->setSessionUser( $this->user, $request );
891 $this->assertFalse( $this->user->isAnon(), 'sanity check' );
892 $this->assertNull( $this->user->getBlock() );
895 public function provideIsPingLimitable() {
896 yield 'Not ip excluded' => [ [], null, true ];
897 yield 'Ip excluded' => [ [ '1.2.3.4' ], null, false ];
898 yield 'Ip subnet excluded' => [ [ '1.2.3.0/8' ], null, false ];
899 yield 'noratelimit right' => [ [], 'noratelimit', false ];
903 * @dataProvider provideIsPingLimitable
904 * @covers User::isPingLimitable
905 * @param array $rateLimitExcludeIps
906 * @param string|null $rightOverride
907 * @param bool $expected
909 public function testIsPingLimitable(
910 array $rateLimitExcludeIps,
911 ?string $rightOverride,
912 bool $expected
914 $request = new FauxRequest();
915 $request->setIP( '1.2.3.4' );
916 $user = User::newFromSession( $request );
917 // We are trying to test for current user behaviour
918 // since we are interested in request IP
919 RequestContext::getMain()->setUser( $user );
921 $this->setMwGlobals( 'wgRateLimitsExcludedIPs', $rateLimitExcludeIps );
922 if ( $rightOverride ) {
923 $this->overrideUserPermissions( $user, $rightOverride );
925 $this->assertSame( $expected, $user->isPingLimitable() );
928 public function provideExperienceLevel() {
929 return [
930 [ 2, 2, 'newcomer' ],
931 [ 12, 3, 'newcomer' ],
932 [ 8, 5, 'newcomer' ],
933 [ 15, 10, 'learner' ],
934 [ 450, 20, 'learner' ],
935 [ 460, 33, 'learner' ],
936 [ 525, 28, 'learner' ],
937 [ 538, 33, 'experienced' ],
938 [ 9, null, 'newcomer' ],
939 [ 10, null, 'learner' ],
940 [ 501, null, 'experienced' ],
945 * @covers User::getExperienceLevel
946 * @dataProvider provideExperienceLevel
948 public function testExperienceLevel( $editCount, $memberSince, $expLevel ) {
949 $this->setMwGlobals( [
950 'wgLearnerEdits' => 10,
951 'wgLearnerMemberSince' => 4,
952 'wgExperiencedUserEdits' => 500,
953 'wgExperiencedUserMemberSince' => 30,
954 ] );
956 $db = wfGetDB( DB_MASTER );
957 $userQuery = User::getQueryInfo();
958 $row = $db->selectRow(
959 $userQuery['tables'],
960 $userQuery['fields'],
961 [ 'user_id' => $this->user->getId() ],
962 __METHOD__,
964 $userQuery['joins']
966 $row->user_editcount = $editCount;
967 if ( $memberSince !== null ) {
968 $row->user_registration = $db->timestamp( time() - $memberSince * 86400 );
969 } else {
970 $row->user_registration = null;
972 $user = User::newFromRow( $row );
974 $this->assertSame( $expLevel, $user->getExperienceLevel() );
978 * @covers User::getExperienceLevel
980 public function testExperienceLevelAnon() {
981 $user = User::newFromName( '10.11.12.13', false );
983 $this->assertFalse( $user->getExperienceLevel() );
986 public static function provideIsLocallyBlockedProxy() {
987 return [
988 [ '1.2.3.4', '1.2.3.4' ],
989 [ '1.2.3.4', '1.2.3.0/16' ],
994 * @covers User::newFromId
996 public function testNewFromId() {
997 $userId = $this->user->getId();
998 $this->assertGreaterThan(
1000 $userId,
1001 'Sanity check: user has a working id'
1004 $otherUser = User::newFromId( $userId );
1005 $this->assertTrue(
1006 $this->user->equals( $otherUser ),
1007 'User created by id should match user with that id'
1012 * @covers User::newFromActorId
1014 public function testActorId() {
1015 // Newly-created user has an actor ID
1016 $user = User::createNew( 'UserTestActorId1' );
1017 $id = $user->getId();
1018 $this->assertGreaterThan( 0, $user->getActorId(), 'User::createNew sets an actor ID' );
1020 $user = User::newFromName( 'UserTestActorId2' );
1021 $user->addToDatabase();
1022 $this->assertGreaterThan( 0, $user->getActorId(), 'User::addToDatabase sets an actor ID' );
1024 $user = User::newFromName( 'UserTestActorId1' );
1025 $this->assertGreaterThan( 0, $user->getActorId(),
1026 'Actor ID can be retrieved for user loaded by name' );
1028 $user = User::newFromId( $id );
1029 $this->assertGreaterThan( 0, $user->getActorId(),
1030 'Actor ID can be retrieved for user loaded by ID' );
1032 $user2 = User::newFromActorId( $user->getActorId() );
1033 $this->assertSame( $user->getId(), $user2->getId(),
1034 'User::newFromActorId works for an existing user' );
1036 $queryInfo = User::getQueryInfo();
1037 $row = $this->db->selectRow( $queryInfo['tables'],
1038 $queryInfo['fields'], [ 'user_id' => $id ], __METHOD__ );
1039 $user = User::newFromRow( $row );
1040 $this->assertGreaterThan( 0, $user->getActorId(),
1041 'Actor ID can be retrieved for user loaded with User::selectFields()' );
1043 $user = User::newFromId( $id );
1044 $user->setName( 'UserTestActorId4-renamed' );
1045 $user->saveSettings();
1046 $this->assertSame(
1047 $user->getName(),
1048 $this->db->selectField(
1049 'actor', 'actor_name', [ 'actor_id' => $user->getActorId() ], __METHOD__
1051 'User::saveSettings updates actor table for name change'
1054 // For sanity
1055 $ip = '192.168.12.34';
1056 $this->db->delete( 'actor', [ 'actor_name' => $ip ], __METHOD__ );
1058 $user = User::newFromName( $ip, false );
1059 $this->assertSame( 0, $user->getActorId(), 'Anonymous user has no actor ID by default' );
1060 $this->assertGreaterThan( 0, $user->getActorId( $this->db ),
1061 'Actor ID can be created for an anonymous user' );
1063 $user = User::newFromName( $ip, false );
1064 $this->assertGreaterThan( 0, $user->getActorId(),
1065 'Actor ID can be loaded for an anonymous user' );
1066 $user2 = User::newFromActorId( $user->getActorId() );
1067 $this->assertSame( $user->getName(), $user2->getName(),
1068 'User::newFromActorId works for an anonymous user' );
1072 * @covers User::newFromAnyId
1074 public function testNewFromAnyId() {
1075 // Registered user
1076 $user = $this->user;
1077 for ( $i = 1; $i <= 7; $i++ ) {
1078 $test = User::newFromAnyId(
1079 ( $i & 1 ) ? $user->getId() : null,
1080 ( $i & 2 ) ? $user->getName() : null,
1081 ( $i & 4 ) ? $user->getActorId() : null
1083 $this->assertSame( $user->getId(), $test->getId() );
1084 $this->assertSame( $user->getName(), $test->getName() );
1085 $this->assertSame( $user->getActorId(), $test->getActorId() );
1088 // Anon user. Can't load by only user ID when that's 0.
1089 $user = User::newFromName( '192.168.12.34', false );
1090 $user->getActorId( $this->db ); // Make sure an actor ID exists
1092 $test = User::newFromAnyId( null, '192.168.12.34', null );
1093 $this->assertSame( $user->getId(), $test->getId() );
1094 $this->assertSame( $user->getName(), $test->getName() );
1095 $this->assertSame( $user->getActorId(), $test->getActorId() );
1096 $test = User::newFromAnyId( null, null, $user->getActorId() );
1097 $this->assertSame( $user->getId(), $test->getId() );
1098 $this->assertSame( $user->getName(), $test->getName() );
1099 $this->assertSame( $user->getActorId(), $test->getActorId() );
1101 // Bogus data should still "work" as long as nothing triggers a ->load(),
1102 // and accessing the specified data shouldn't do that.
1103 $test = User::newFromAnyId( 123456, 'Bogus', 654321 );
1104 $this->assertSame( 123456, $test->getId() );
1105 $this->assertSame( 'Bogus', $test->getName() );
1106 $this->assertSame( 654321, $test->getActorId() );
1108 // Loading remote user by name from remote wiki should succeed
1109 $test = User::newFromAnyId( null, 'Bogus', null, 'foo' );
1110 $this->assertSame( 0, $test->getId() );
1111 $this->assertSame( 'Bogus', $test->getName() );
1112 $this->assertSame( 0, $test->getActorId() );
1113 $test = User::newFromAnyId( 123456, 'Bogus', 654321, 'foo' );
1114 $this->assertSame( 0, $test->getId() );
1115 $this->assertSame( 0, $test->getActorId() );
1117 // Exceptional cases
1118 try {
1119 User::newFromAnyId( null, null, null );
1120 $this->fail( 'Expected exception not thrown' );
1121 } catch ( InvalidArgumentException $ex ) {
1123 try {
1124 User::newFromAnyId( 0, null, 0 );
1125 $this->fail( 'Expected exception not thrown' );
1126 } catch ( InvalidArgumentException $ex ) {
1129 // Loading remote user by id from remote wiki should fail
1130 try {
1131 User::newFromAnyId( 123456, null, 654321, 'foo' );
1132 $this->fail( 'Expected exception not thrown' );
1133 } catch ( InvalidArgumentException $ex ) {
1138 * @covers User::newFromIdentity
1140 public function testNewFromIdentity() {
1141 // Registered user
1142 $user = $this->user;
1144 $this->assertSame( $user, User::newFromIdentity( $user ) );
1146 // ID only
1147 $identity = new UserIdentityValue( $user->getId(), '', 0 );
1148 $result = User::newFromIdentity( $identity );
1149 $this->assertInstanceOf( User::class, $result );
1150 $this->assertSame( $user->getId(), $result->getId(), 'ID' );
1151 $this->assertSame( $user->getName(), $result->getName(), 'Name' );
1152 $this->assertSame( $user->getActorId(), $result->getActorId(), 'Actor' );
1154 // Name only
1155 $identity = new UserIdentityValue( 0, $user->getName(), 0 );
1156 $result = User::newFromIdentity( $identity );
1157 $this->assertInstanceOf( User::class, $result );
1158 $this->assertSame( $user->getId(), $result->getId(), 'ID' );
1159 $this->assertSame( $user->getName(), $result->getName(), 'Name' );
1160 $this->assertSame( $user->getActorId(), $result->getActorId(), 'Actor' );
1162 // Actor only
1163 $identity = new UserIdentityValue( 0, '', $user->getActorId() );
1164 $result = User::newFromIdentity( $identity );
1165 $this->assertInstanceOf( User::class, $result );
1166 $this->assertSame( $user->getId(), $result->getId(), 'ID' );
1167 $this->assertSame( $user->getName(), $result->getName(), 'Name' );
1168 $this->assertSame( $user->getActorId(), $result->getActorId(), 'Actor' );
1172 * @covers User::newFromConfirmationCode
1174 public function testNewFromConfirmationCode() {
1175 $user = User::newFromConfirmationCode( 'NotARealConfirmationCode' );
1176 $this->assertNull(
1177 $user,
1178 'Invalid confirmation codes result in null users when reading from replicas'
1181 $user = User::newFromConfirmationCode( 'OtherFakeCode', User::READ_LATEST );
1182 $this->assertNull(
1183 $user,
1184 'Invalid confirmation codes result in null users when reading from master'
1189 * @covers User::newFromName
1190 * @covers User::getName
1191 * @covers User::getUserPage
1192 * @covers User::getTalkPage
1193 * @covers User::getTitleKey
1194 * @covers User::whoIs
1195 * @dataProvider provideNewFromName
1197 public function testNewFromName( $name, $titleKey ) {
1198 $user = User::newFromName( $name );
1199 $this->assertSame( $user->getName(), $name );
1200 $this->assertEquals( $user->getUserPage(), Title::makeTitle( NS_USER, $name ) );
1201 $this->assertEquals( $user->getTalkPage(), Title::makeTitle( NS_USER_TALK, $name ) );
1202 $this->assertSame( $user->getTitleKey(), $titleKey );
1204 $status = $user->addToDatabase();
1205 $this->assertTrue( $status->isOK(), 'User can be added to the database' );
1206 $this->assertSame( $name, User::whoIs( $user->getId() ) );
1209 public static function provideNewFromName() {
1210 return [
1211 [ 'Example1', 'Example1' ],
1212 [ 'Mediawiki easter egg', 'Mediawiki_easter_egg' ],
1213 [ 'See T22281 for more', 'See_T22281_for_more' ],
1214 [ 'DannyS712', 'DannyS712' ],
1219 * @covers User::newFromName
1221 public function testNewFromName_extra() {
1222 $user = User::newFromName( '1.2.3.4' );
1223 $this->assertFalse( $user, 'IP addresses are not valid user names' );
1225 $user = User::newFromName( 'DannyS712', true );
1226 $otherUser = User::newFromName( 'DannyS712', 'valid' );
1227 $this->assertTrue(
1228 $user->equals( $otherUser ),
1229 'true maps to valid for backwards compatibility'
1234 * @covers User::newFromSession
1235 * @covers User::getRequest
1237 public function testSessionAndRequest() {
1238 $req1 = new WebRequest;
1239 $this->setRequest( $req1 );
1240 $user = User::newFromSession();
1241 $request = $user->getRequest();
1243 $this->assertSame(
1244 $req1,
1245 $request,
1246 'Creating a user without a request defaults to $wgRequest'
1248 $req2 = new WebRequest;
1249 $this->assertNotSame(
1250 $req1,
1251 $req2,
1252 'Sanity check: passing a request that does not match $wgRequest'
1254 $user = User::newFromSession( $req2 );
1255 $request = $user->getRequest();
1256 $this->assertSame(
1257 $req2,
1258 $request,
1259 'Creating a user by passing a WebRequest successfully sets the request, ' .
1260 'instead of using $wgRequest'
1265 * @covers User::newFromRow
1266 * @covers User::loadFromRow
1268 public function testNewFromRow() {
1269 // TODO: Create real tests here for loadFromRow
1270 $row = (object)[];
1271 $user = User::newFromRow( $row );
1272 $this->assertInstanceOf( User::class, $user, 'newFromRow returns a user object' );
1276 * @covers User::newFromRow
1277 * @covers User::loadFromRow
1279 public function testNewFromRow_bad() {
1280 $this->expectException( InvalidArgumentException::class );
1281 $this->expectExceptionMessage( '$row must be an object' );
1282 User::newFromRow( [] );
1286 * @covers User::getBlockedStatus
1287 * @covers User::getBlockId
1288 * @covers User::getBlock
1289 * @covers User::blockedBy
1290 * @covers User::blockedFor
1291 * @covers User::isHidden
1292 * @covers User::isBlockedFrom
1294 public function testBlockInstanceCache() {
1295 // First, check the user isn't blocked
1296 $user = $this->getMutableTestUser()->getUser();
1297 $ut = Title::makeTitle( NS_USER_TALK, $user->getName() );
1298 $this->assertNull( $user->getBlock( false ), 'sanity check' );
1299 $this->assertSame( '', $user->blockedBy(), 'sanity check' );
1300 $this->assertSame( '', $user->blockedFor(), 'sanity check' );
1301 $this->assertFalse( $user->isHidden(), 'sanity check' );
1302 $this->assertFalse( $user->isBlockedFrom( $ut ), 'sanity check' );
1304 // Block the user
1305 $blocker = $this->getTestSysop()->getUser();
1306 $block = new DatabaseBlock( [
1307 'hideName' => true,
1308 'allowUsertalk' => false,
1309 'reason' => 'Because',
1310 ] );
1311 $block->setTarget( $user );
1312 $block->setBlocker( $blocker );
1313 $blockStore = MediaWikiServices::getInstance()->getDatabaseBlockStore();
1314 $res = $blockStore->insertBlock( $block );
1315 $this->assertTrue( (bool)$res['id'], 'sanity check: Failed to insert block' );
1317 // Clear cache and confirm it loaded the block properly
1318 $user->clearInstanceCache();
1319 $this->assertInstanceOf( DatabaseBlock::class, $user->getBlock( false ) );
1320 $this->assertSame( $blocker->getName(), $user->blockedBy() );
1321 $this->assertSame( 'Because', $user->blockedFor() );
1322 $this->assertTrue( $user->isHidden() );
1323 $this->assertTrue( $user->isBlockedFrom( $ut ) );
1324 $this->assertSame( $res['id'], $user->getBlockId() );
1326 // Unblock
1327 $blockStore->deleteBlock( $block );
1329 // Clear cache and confirm it loaded the not-blocked properly
1330 $user->clearInstanceCache();
1331 $this->assertNull( $user->getBlock( false ) );
1332 $this->assertSame( '', $user->blockedBy() );
1333 $this->assertSame( '', $user->blockedFor() );
1334 $this->assertFalse( $user->isHidden() );
1335 $this->assertFalse( $user->isBlockedFrom( $ut ) );
1336 $this->assertFalse( $user->getBlockId() );
1340 * @covers User::getBlockedStatus
1342 public function testCompositeBlocks() {
1343 $user = $this->getMutableTestUser()->getUser();
1344 $request = $user->getRequest();
1345 $this->setSessionUser( $user, $request );
1347 $blockStore = MediaWikiServices::getInstance()->getDatabaseBlockStore();
1348 $ipBlock = new Block( [
1349 'address' => $user->getRequest()->getIP(),
1350 'by' => $this->getTestSysop()->getUser()->getId(),
1351 'createAccount' => true,
1352 ] );
1353 $blockStore->insertBlock( $ipBlock );
1355 $userBlock = new Block( [
1356 'address' => $user,
1357 'by' => $this->getTestSysop()->getUser()->getId(),
1358 'createAccount' => false,
1359 ] );
1360 $blockStore->insertBlock( $userBlock );
1362 $block = $user->getBlock();
1363 $this->assertInstanceOf( CompositeBlock::class, $block );
1364 $this->assertTrue( $block->isCreateAccountBlocked() );
1365 $this->assertTrue( $block->appliesToPasswordReset() );
1366 $this->assertTrue( $block->appliesToNamespace( NS_MAIN ) );
1370 * @covers User::isBlockedFrom
1371 * @dataProvider provideIsBlockedFrom
1372 * @param string|null $title Title to test.
1373 * @param bool $expect Expected result from User::isBlockedFrom()
1374 * @param array $options Additional test options:
1375 * - 'blockAllowsUTEdit': (bool, default true) Value for $wgBlockAllowsUTEdit
1376 * - 'allowUsertalk': (bool, default false) Passed to DatabaseBlock::__construct()
1377 * - 'pageRestrictions': (array|null) If non-empty, page restriction titles for the block.
1379 public function testIsBlockedFrom( $title, $expect, array $options = [] ) {
1380 $this->setMwGlobals( [
1381 'wgBlockAllowsUTEdit' => $options['blockAllowsUTEdit'] ?? true,
1382 ] );
1384 $user = $this->user;
1386 if ( $title === self::USER_TALK_PAGE ) {
1387 $title = $user->getTalkPage();
1388 } else {
1389 $title = Title::newFromText( $title );
1392 $restrictions = [];
1393 foreach ( $options['pageRestrictions'] ?? [] as $pagestr ) {
1394 $page = $this->getExistingTestPage(
1395 $pagestr === self::USER_TALK_PAGE ? $user->getTalkPage() : $pagestr
1397 $restrictions[] = new PageRestriction( 0, $page->getId() );
1399 foreach ( $options['namespaceRestrictions'] ?? [] as $ns ) {
1400 $restrictions[] = new NamespaceRestriction( 0, $ns );
1403 $block = new DatabaseBlock( [
1404 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ),
1405 'allowUsertalk' => $options['allowUsertalk'] ?? false,
1406 'sitewide' => !$restrictions,
1407 ] );
1408 $block->setTarget( $user );
1409 $block->setBlocker( $this->getTestSysop()->getUser() );
1410 if ( $restrictions ) {
1411 $block->setRestrictions( $restrictions );
1413 $blockStore = MediaWikiServices::getInstance()->getDatabaseBlockStore();
1414 $blockStore->insertBlock( $block );
1416 try {
1417 $this->assertSame( $expect, $user->isBlockedFrom( $title ) );
1418 } finally {
1419 $blockStore->deleteBlock( $block );
1423 public static function provideIsBlockedFrom() {
1424 return [
1425 'Sitewide block, basic operation' => [ 'Test page', true ],
1426 'Sitewide block, not allowing user talk' => [
1427 self::USER_TALK_PAGE, true, [
1428 'allowUsertalk' => false,
1431 'Sitewide block, allowing user talk' => [
1432 self::USER_TALK_PAGE, false, [
1433 'allowUsertalk' => true,
1436 'Sitewide block, allowing user talk but $wgBlockAllowsUTEdit is false' => [
1437 self::USER_TALK_PAGE, true, [
1438 'allowUsertalk' => true,
1439 'blockAllowsUTEdit' => false,
1442 'Partial block, blocking the page' => [
1443 'Test page', true, [
1444 'pageRestrictions' => [ 'Test page' ],
1447 'Partial block, not blocking the page' => [
1448 'Test page 2', false, [
1449 'pageRestrictions' => [ 'Test page' ],
1452 'Partial block, not allowing user talk but user talk page is not blocked' => [
1453 self::USER_TALK_PAGE, false, [
1454 'allowUsertalk' => false,
1455 'pageRestrictions' => [ 'Test page' ],
1458 'Partial block, allowing user talk but user talk page is blocked' => [
1459 self::USER_TALK_PAGE, true, [
1460 'allowUsertalk' => true,
1461 'pageRestrictions' => [ self::USER_TALK_PAGE ],
1464 'Partial block, user talk page is not blocked but $wgBlockAllowsUTEdit is false' => [
1465 self::USER_TALK_PAGE, false, [
1466 'allowUsertalk' => false,
1467 'pageRestrictions' => [ 'Test page' ],
1468 'blockAllowsUTEdit' => false,
1471 'Partial block, user talk page is blocked and $wgBlockAllowsUTEdit is false' => [
1472 self::USER_TALK_PAGE, true, [
1473 'allowUsertalk' => true,
1474 'pageRestrictions' => [ self::USER_TALK_PAGE ],
1475 'blockAllowsUTEdit' => false,
1478 'Partial user talk namespace block, not allowing user talk' => [
1479 self::USER_TALK_PAGE, true, [
1480 'allowUsertalk' => false,
1481 'namespaceRestrictions' => [ NS_USER_TALK ],
1484 'Partial user talk namespace block, allowing user talk' => [
1485 self::USER_TALK_PAGE, false, [
1486 'allowUsertalk' => true,
1487 'namespaceRestrictions' => [ NS_USER_TALK ],
1490 'Partial user talk namespace block, where $wgBlockAllowsUTEdit is false' => [
1491 self::USER_TALK_PAGE, true, [
1492 'allowUsertalk' => true,
1493 'namespaceRestrictions' => [ NS_USER_TALK ],
1494 'blockAllowsUTEdit' => false,
1501 * @covers User::isBlockedFromEmailuser
1502 * @covers User::isAllowedToCreateAccount
1503 * @dataProvider provideIsBlockedFromAction
1504 * @param bool $blockFromEmail Whether to block email access.
1505 * @param bool $blockFromAccountCreation Whether to block account creation.
1507 public function testIsBlockedFromAction( $blockFromEmail, $blockFromAccountCreation ) {
1508 $user = $this->getTestUser( 'accountcreator' )->getUser();
1510 $block = new DatabaseBlock( [
1511 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ),
1512 'sitewide' => true,
1513 'blockEmail' => $blockFromEmail,
1514 'createAccount' => $blockFromAccountCreation
1515 ] );
1516 $block->setTarget( $user );
1517 $block->setBlocker( $this->getTestSysop()->getUser() );
1518 $blockStore = MediaWikiServices::getInstance()->getDatabaseBlockStore();
1519 $blockStore->insertBlock( $block );
1521 try {
1522 $this->assertSame( $blockFromEmail, $user->isBlockedFromEmailuser() );
1523 $this->assertSame( !$blockFromAccountCreation, $user->isAllowedToCreateAccount() );
1524 } finally {
1525 $blockStore->deleteBlock( $block );
1529 public static function provideIsBlockedFromAction() {
1530 return [
1531 'Block email access and account creation' => [ true, true ],
1532 'Block only email access' => [ true, false ],
1533 'Block only account creation' => [ false, true ],
1534 'Allow email access and account creation' => [ false, false ],
1539 * @covers User::isBlockedFromUpload
1540 * @dataProvider provideIsBlockedFromUpload
1541 * @param bool $sitewide Whether to block sitewide.
1542 * @param bool $expected Whether the user is expected to be blocked from uploads.
1544 public function testIsBlockedFromUpload( $sitewide, $expected ) {
1545 $block = new DatabaseBlock( [
1546 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ),
1547 'sitewide' => $sitewide,
1548 ] );
1549 $block->setTarget( $this->user );
1550 $block->setBlocker( $this->getTestSysop()->getUser() );
1551 $blockStore = MediaWikiServices::getInstance()->getDatabaseBlockStore();
1552 $blockStore->insertBlock( $block );
1554 try {
1555 $this->assertSame( $expected, $this->user->isBlockedFromUpload() );
1556 } finally {
1557 $blockStore->deleteBlock( $block );
1561 public static function provideIsBlockedFromUpload() {
1562 return [
1563 'sitewide blocks block uploads' => [ true, true ],
1564 'partial blocks allow uploads' => [ false, false ],
1569 * @covers User::getFirstEditTimestamp
1570 * @covers User::getLatestEditTimestamp
1572 public function testGetFirstLatestEditTimestamp() {
1573 $clock = MWTimestamp::convert( TS_UNIX, '20100101000000' );
1574 MWTimestamp::setFakeTime( function () use ( &$clock ) {
1575 return $clock += 1000;
1576 } );
1577 try {
1578 $user = $this->user;
1579 $firstRevision = self::makeEdit( $user, 'Help:UserTest_GetEditTimestamp', 'one', 'test' );
1580 $secondRevision = self::makeEdit( $user, 'Help:UserTest_GetEditTimestamp', 'two', 'test' );
1581 // Sanity check: revisions timestamp are different
1582 $this->assertNotEquals( $firstRevision->getTimestamp(), $secondRevision->getTimestamp() );
1584 $this->assertSame( $firstRevision->getTimestamp(), $user->getFirstEditTimestamp() );
1585 $this->assertSame( $secondRevision->getTimestamp(), $user->getLatestEditTimestamp() );
1586 } finally {
1587 MWTimestamp::setFakeTime( false );
1592 * @param User $user
1593 * @param string $title
1594 * @param string $content
1595 * @param string $comment
1596 * @return \MediaWiki\Revision\RevisionRecord|null
1598 private static function makeEdit( User $user, $title, $content, $comment ) {
1599 $page = WikiPage::factory( Title::newFromText( $title ) );
1600 $content = ContentHandler::makeContent( $content, $page->getTitle() );
1601 $updater = $page->newPageUpdater( $user );
1602 $updater->setContent( 'main', $content );
1603 return $updater->saveRevision( CommentStoreComment::newUnsavedComment( $comment ) );
1607 * @covers User::idFromName
1609 public function testExistingIdFromName() {
1610 $this->assertTrue(
1611 array_key_exists( $this->user->getName(), User::$idCacheByName ),
1612 'Test user should already be in the id cache.'
1614 $this->assertSame(
1615 $this->user->getId(), User::idFromName( $this->user->getName() ),
1616 'Id is correctly retreived from the cache.'
1618 $this->assertSame(
1619 $this->user->getId(), User::idFromName( $this->user->getName(), User::READ_LATEST ),
1620 'Id is correctly retreived from the database.'
1625 * @covers User::idFromName
1627 public function testNonExistingIdFromName() {
1628 $this->assertFalse(
1629 array_key_exists( 'NotExisitngUser', User::$idCacheByName ),
1630 'Non exisitng user should not be in the id cache.'
1632 $this->assertNull( User::idFromName( 'NotExisitngUser' ) );
1633 $this->assertTrue(
1634 array_key_exists( 'NotExisitngUser', User::$idCacheByName ),
1635 'Username will be cached when requested once.'
1637 $this->assertNull( User::idFromName( 'NotExistingUser' ) );
1638 $this->assertNull( User::idFromName( 'Illegal|Name' ) );
1642 * @covers User::isSystemUser
1644 public function testIsSystemUser() {
1645 $this->assertFalse( $this->user->isSystemUser(), 'Normal users are not system users' );
1647 $user = User::newSystemUser( __METHOD__ );
1648 $this->assertTrue( $user->isSystemUser(), 'Users created with newSystemUser() are system users' );
1652 * @covers User::newSystemUser
1653 * @dataProvider provideNewSystemUser
1654 * @param string $exists How/whether to create the user before calling User::newSystemUser
1655 * - 'missing': Do not create the user
1656 * - 'actor': Create an anonymous actor
1657 * - 'user': Create a non-system user
1658 * - 'system': Create a system user
1659 * @param string $options Options to User::newSystemUser
1660 * @param array $testOpts Test options
1661 * @param string $expect 'user', 'exception', or 'null'
1663 public function testNewSystemUser( $exists, $options, $testOpts, $expect ) {
1664 $origUser = null;
1665 $actorId = null;
1667 switch ( $exists ) {
1668 case 'missing':
1669 $name = 'TestNewSystemUser ' . TestUserRegistry::getNextId();
1670 break;
1672 case 'actor':
1673 $name = 'TestNewSystemUser ' . TestUserRegistry::getNextId();
1674 $this->db->insert( 'actor', [ 'actor_name' => $name ] );
1675 $actorId = (int)$this->db->insertId();
1676 break;
1678 case 'user':
1679 $origUser = $this->getMutableTestUser()->getUser();
1680 $name = $origUser->getName();
1681 $actorId = $origUser->getActorId();
1682 break;
1684 case 'system':
1685 $name = 'TestNewSystemUser ' . TestUserRegistry::getNextId();
1686 $user = User::newSystemUser( $name ); // Heh.
1687 $actorId = $user->getActorId();
1688 // Use this hook as a proxy for detecting when a "steal" happens.
1689 $this->setTemporaryHook( 'InvalidateEmailComplete', function () {
1690 $this->fail( 'InvalidateEmailComplete hook should not have been called' );
1691 } );
1692 break;
1695 $globals = $testOpts['globals'] ?? [];
1696 if ( !empty( $testOpts['reserved'] ) ) {
1697 $globals['wgReservedUsernames'] = [ $name ];
1699 $this->setMwGlobals( $globals );
1700 $this->assertTrue( User::isValidUserName( $name ) );
1701 $this->assertSame( empty( $testOpts['reserved'] ), User::isUsableName( $name ) );
1703 if ( $expect === 'exception' ) {
1704 // T248195: Duplicate entry errors will log the exception, don't fail because of that.
1705 $this->setNullLogger( 'DBQuery' );
1706 $this->expectException( Exception::class );
1708 $user = User::newSystemUser( $name, $options );
1709 if ( $expect === 'null' ) {
1710 $this->assertNull( $user );
1711 if ( $origUser ) {
1712 $this->assertNotSame(
1713 User::INVALID_TOKEN, TestingAccessWrapper::newFromObject( $origUser )->mToken
1715 $this->assertNotSame( '', $origUser->getEmail() );
1716 $this->assertFalse( $origUser->isSystemUser(), 'Normal users should not be system users' );
1718 } else {
1719 $this->assertInstanceOf( User::class, $user );
1720 $this->assertSame( $name, $user->getName() );
1721 if ( $actorId !== null ) {
1722 $this->assertSame( $actorId, $user->getActorId() );
1724 $this->assertSame( User::INVALID_TOKEN, TestingAccessWrapper::newFromObject( $user )->mToken );
1725 $this->assertSame( '', $user->getEmail() );
1726 $this->assertTrue( $user->isSystemUser(), 'Newly created system users should be system users' );
1730 public static function provideNewSystemUser() {
1731 return [
1732 'Basic creation' => [ 'missing', [], [], 'user' ],
1733 'No creation' => [ 'missing', [ 'create' => false ], [], 'null' ],
1734 'Validation fail' => [
1735 'missing',
1736 [ 'validate' => 'usable' ],
1737 [ 'reserved' => true ],
1738 'null'
1740 'No stealing' => [ 'user', [], [], 'null' ],
1741 'Stealing allowed' => [ 'user', [ 'steal' => true ], [], 'user' ],
1742 'Stealing an already-system user' => [ 'system', [ 'steal' => true ], [], 'user' ],
1743 'Anonymous actor (T236444)' => [ 'actor', [], [ 'reserved' => true ], 'user' ],
1744 'Reserved but no anonymous actor' => [ 'missing', [], [ 'reserved' => true ], 'user' ],
1745 'Anonymous actor but no creation' => [ 'actor', [ 'create' => false ], [], 'null' ],
1746 'Anonymous actor but not reserved' => [ 'actor', [], [], 'exception' ],
1751 * @covers User::getDefaultOption
1752 * @covers User::getDefaultOptions
1754 public function testGetDefaultOptions() {
1755 $this->resetServices();
1757 $this->setTemporaryHook( 'UserGetDefaultOptions', function ( &$defaults ) {
1758 $defaults['extraoption'] = 42;
1759 } );
1761 $defaultOptions = User::getDefaultOptions();
1762 $this->assertArrayHasKey( 'search-match-redirect', $defaultOptions );
1763 $this->assertArrayHasKey( 'extraoption', $defaultOptions );
1765 $extraOption = User::getDefaultOption( 'extraoption' );
1766 $this->assertSame( 42, $extraOption );
1770 * @covers User::getAutomaticGroups
1772 public function testGetAutomaticGroups() {
1773 $this->assertArrayEquals( [
1774 '*',
1775 'user',
1776 'autoconfirmed'
1777 ], $this->user->getAutomaticGroups( true ) );
1779 $user = $this->getTestUser( [ 'bureaucrat', 'test' ] )->getUser();
1780 $this->assertArrayEquals( [
1781 '*',
1782 'user',
1783 'autoconfirmed'
1784 ], $user->getAutomaticGroups( true ) );
1785 $user->addGroup( 'something' );
1786 $this->assertArrayEquals( [
1787 '*',
1788 'user',
1789 'autoconfirmed'
1790 ], $user->getAutomaticGroups( true ) );
1792 $user = User::newFromName( 'UTUser1' );
1793 $this->assertSame( [ '*' ], $user->getAutomaticGroups( true ) );
1794 $this->setMwGlobals( [
1795 'wgAutopromote' => [
1796 'dummy' => APCOND_EMAILCONFIRMED
1798 ] );
1800 $this->user->confirmEmail();
1801 $this->assertArrayEquals( [
1802 '*',
1803 'user',
1804 'dummy'
1805 ], $this->user->getAutomaticGroups( true ) );
1807 $user = $this->getTestUser( [ 'dummy' ] )->getUser();
1808 $user->confirmEmail();
1809 $this->assertArrayEquals( [
1810 '*',
1811 'user',
1812 'dummy'
1813 ], $user->getAutomaticGroups( true ) );
1817 * @covers User::getEffectiveGroups
1819 public function testGetEffectiveGroups() {
1820 $user = $this->getTestUser()->getUser();
1821 $this->assertArrayEquals( [
1822 '*',
1823 'user',
1824 'autoconfirmed'
1825 ], $user->getEffectiveGroups( true ) );
1827 $user = $this->getTestUser( [ 'bureaucrat', 'test' ] )->getUser();
1828 $this->assertArrayEquals( [
1829 '*',
1830 'user',
1831 'autoconfirmed',
1832 'bureaucrat',
1833 'test'
1834 ], $user->getEffectiveGroups( true ) );
1836 $user = $this->getTestUser( [ 'autoconfirmed', 'test' ] )->getUser();
1837 $this->assertArrayEquals( [
1838 '*',
1839 'user',
1840 'autoconfirmed',
1841 'test'
1842 ], $user->getEffectiveGroups( true ) );
1846 * @covers User::getGroups
1848 public function testGetGroups() {
1849 $user = $this->getTestUser( [ 'a', 'b' ] )->getUser();
1850 $this->assertArrayEquals( [ 'a', 'b' ], $user->getGroups() );
1854 * @covers User::getFormerGroups
1856 public function testGetFormerGroups() {
1857 $user = $this->getTestUser( [ 'a', 'b', 'c' ] )->getUser();
1858 $this->assertArrayEquals( [], $user->getFormerGroups() );
1859 $user->addGroup( 'test' );
1860 $user->removeGroup( 'test' );
1861 $this->assertArrayEquals( [ 'test' ], $user->getFormerGroups() );
1865 * @covers User::addGroup
1867 public function testAddGroup() {
1868 $user = $this->getTestUser()->getUser();
1869 $this->assertSame( [], $user->getGroups() );
1871 $this->assertTrue( $user->addGroup( 'test' ) );
1872 $this->assertArrayEquals( [ 'test' ], $user->getGroups() );
1874 $this->assertTrue( $user->addGroup( 'test2' ) );
1875 $this->assertArrayEquals( [ 'test', 'test2' ], $user->getGroups() );
1877 $this->setTemporaryHook( 'UserAddGroup', function ( $user, &$group, &$expiry ) {
1878 return false;
1879 } );
1880 $this->assertFalse( $user->addGroup( 'test3' ) );
1881 $this->assertArrayEquals(
1882 [ 'test', 'test2' ],
1883 $user->getGroups(),
1884 'Hooks can stop addition of a group'
1889 * @covers User::removeGroup
1891 public function testRemoveGroup() {
1892 $user = $this->getTestUser( [ 'test', 'test3' ] )->getUser();
1894 $this->assertTrue( $user->removeGroup( 'test' ) );
1895 $this->assertSame( [ 'test3' ], $user->getGroups() );
1897 $this->assertFalse(
1898 $user->removeGroup( 'test2' ),
1899 'A group membership that does not exist cannot be removed'
1902 $this->setTemporaryHook( 'UserRemoveGroup', function ( $user, &$group ) {
1903 return false;
1904 } );
1906 $this->assertFalse( $user->removeGroup( 'test3' ) );
1907 $this->assertSame( [ 'test3' ], $user->getGroups(), 'Hooks can stop removal of a group' );
1910 private const CHANGEABLE_GROUPS_TEST_CONFIG = [
1911 'wgGroupPermissions' => [
1912 'doEverything' => [
1913 'userrights' => true,
1916 'wgAddGroups' => [
1917 'sysop' => [ 'rollback' ],
1918 'bureaucrat' => [ 'sysop', 'bureaucrat' ],
1920 'wgRemoveGroups' => [
1921 'sysop' => [ 'rollback' ],
1922 'bureaucrat' => [ 'sysop' ],
1924 'wgGroupsAddToSelf' => [
1925 'sysop' => [ 'flood' ],
1927 'wgGroupsRemoveFromSelf' => [
1928 'flood' => [ 'flood' ],
1933 * @covers User::changeableGroups
1935 public function testChangeableGroups() {
1936 $this->setMwGlobals( self::CHANGEABLE_GROUPS_TEST_CONFIG );
1938 $allGroups = User::getAllGroups();
1940 $user = $this->getTestUser( [ 'doEverything' ] )->getUser();
1941 $changeableGroups = $user->changeableGroups();
1942 $this->assertGroupsEquals(
1944 'add' => $allGroups,
1945 'remove' => $allGroups,
1946 'add-self' => [],
1947 'remove-self' => [],
1949 $changeableGroups
1952 $user = $this->getTestUser( [ 'bureaucrat', 'sysop' ] )->getUser();
1953 $changeableGroups = $user->changeableGroups();
1954 $this->assertGroupsEquals(
1956 'add' => [ 'bureaucrat', 'sysop', 'rollback' ],
1957 'remove' => [ 'sysop', 'rollback' ],
1958 'add-self' => [ 'flood' ],
1959 'remove-self' => [],
1961 $changeableGroups
1964 $user = $this->getTestUser( [ 'flood' ] )->getUser();
1965 $changeableGroups = $user->changeableGroups();
1966 $this->assertGroupsEquals(
1968 'add' => [],
1969 'remove' => [],
1970 'add-self' => [],
1971 'remove-self' => [ 'flood' ],
1973 $changeableGroups
1977 public function provideChangeableByGroup() {
1978 yield 'sysop' => [ 'sysop', [
1979 'add' => [ 'rollback' ],
1980 'remove' => [ 'rollback' ],
1981 'add-self' => [ 'flood' ],
1982 'remove-self' => [],
1983 ] ];
1984 yield 'flood' => [ 'flood', [
1985 'add' => [],
1986 'remove' => [],
1987 'add-self' => [],
1988 'remove-self' => [ 'flood' ],
1989 ] ];
1993 * @dataProvider provideChangeableByGroup
1994 * @covers User::changeableByGroup
1995 * @param string $group
1996 * @param array $expected
1998 public function testChangeableByGroup( string $group, array $expected ) {
1999 $this->setMwGlobals( self::CHANGEABLE_GROUPS_TEST_CONFIG );
2000 $this->assertGroupsEquals( $expected, User::changeableByGroup( $group ) );
2003 private function assertGroupsEquals( array $expected, array $actual ) {
2004 // assertArrayEquals can compare without requiring the same order,
2005 // but the elements of an array are still required to be in the same order,
2006 // so just compare each element
2007 $this->assertArrayEquals( $expected['add'], $actual['add'] );
2008 $this->assertArrayEquals( $expected['remove'], $actual['remove'] );
2009 $this->assertArrayEquals( $expected['add-self'], $actual['add-self'] );
2010 $this->assertArrayEquals( $expected['remove-self'], $actual['remove-self'] );
2014 * @covers User::isWatched
2015 * @covers User::isTempWatched
2016 * @covers User::addWatch
2017 * @covers User::removeWatch
2019 public function testWatchlist() {
2020 $user = $this->user;
2021 $articleTitle = Title::makeTitle( NS_MAIN, 'FooBar' );
2023 $this->assertFalse( $user->isWatched( $articleTitle ), 'The article has not been watched yet' );
2025 $user->addWatch( $articleTitle );
2026 $this->assertTrue( $user->isWatched( $articleTitle ), 'The article has been watched' );
2027 $this->assertFalse(
2028 $user->isTempWatched( $articleTitle ),
2029 "The article hasn't been temporarily watched"
2032 $user->removeWatch( $articleTitle );
2033 $this->assertFalse( $user->isWatched( $articleTitle ), 'The article has been unwatched' );
2034 $this->assertFalse(
2035 $user->isTempWatched( $articleTitle ),
2036 "The article hasn't been temporarily watched"
2039 $user->addWatch( $articleTitle, true, '2 weeks' );
2040 $this->assertTrue(
2041 $user->isTempWatched( $articleTitle, 'The article has been tempoarily watched' )
2044 $specialTitle = Title::makeTitle( NS_SPECIAL, 'Version' );
2045 $this->assertFalse( $user->isWatched( $specialTitle ), 'Special pages cannot be watched' );
2046 // Assume no exceptions
2047 $user->addWatch( $specialTitle );
2048 $user->removeWatch( $specialTitle );
2052 * @covers User::getName
2053 * @covers User::setName
2055 public function testUserName() {
2056 $user = User::newFromName( 'DannyS712' );
2057 $this->assertSame(
2058 'DannyS712',
2059 $user->getName(),
2060 'Santiy check: Users created using ::newFromName should return the name used'
2063 $user->setName( 'FooBarBaz' );
2064 $this->assertSame(
2065 'FooBarBaz',
2066 $user->getName(),
2067 'Changing a username via ::setName should be reflected in ::getName'
2072 * @covers User::getEmail
2073 * @covers User::setEmail
2074 * @covers User::invalidateEmail
2076 public function testUserEmail() {
2077 $user = $this->user;
2079 $user->setEmail( 'TestEmail@mediawiki.org' );
2080 $this->assertSame(
2081 'TestEmail@mediawiki.org',
2082 $user->getEmail(),
2083 'Setting an email via ::setEmail should be reflected in ::getEmail'
2086 $this->setTemporaryHook( 'UserSetEmail', function ( $user, &$email ) {
2087 $this->fail(
2088 'UserSetEmail hook should not be called when the new email ' .
2089 'is the same as the old email.'
2091 } );
2092 $user->setEmail( 'TestEmail@mediawiki.org' );
2094 $this->removeTemporaryHook( 'UserSetEmail' );
2096 $this->setTemporaryHook( 'UserSetEmail', function ( $user, &$email ) {
2097 $email = 'SettingIntercepted@mediawiki.org';
2098 } );
2099 $user->setEmail( 'NewEmail@mediawiki.org' );
2100 $this->assertSame(
2101 'SettingIntercepted@mediawiki.org',
2102 $user->getEmail(),
2103 'Hooks can override setting email addresses'
2106 $this->setTemporaryHook( 'UserGetEmail', function ( $user, &$email ) {
2107 $email = 'GettingIntercepted@mediawiki.org';
2108 } );
2109 $this->assertSame(
2110 'GettingIntercepted@mediawiki.org',
2111 $user->getEmail(),
2112 'Hooks can override getting email address'
2115 $this->removeTemporaryHook( 'UserGetEmail' );
2116 $this->removeTemporaryHook( 'UserSetEmail' );
2118 $user->invalidateEmail();
2119 $this->assertSame(
2121 $user->getEmail(),
2122 'After invalidation, a user email should be an empty string'
2127 * @covers User::setEmailWithConfirmation
2129 public function testSetEmailWithConfirmation_basic() {
2130 $user = $this->getTestUser()->getUser();
2131 $startingEmail = 'startingemail@mediawiki.org';
2132 $user->setEmail( $startingEmail );
2134 $this->setMwGlobals( [
2135 'wgEnableEmail' => false,
2136 'wgEmailAuthentication' => false
2137 ] );
2138 $status = $user->setEmailWithConfirmation( 'test1@mediawiki.org' );
2139 $this->assertSame(
2140 $status->getErrors()[0]['message'],
2141 'emaildisabled',
2142 'Cannot set email when email is disabled'
2144 $this->assertSame(
2145 $user->getEmail(),
2146 $startingEmail,
2147 'Email has not changed'
2150 $this->setMwGlobals( [
2151 'wgEnableEmail' => true,
2152 ] );
2153 $status = $user->setEmailWithConfirmation( $startingEmail );
2154 $this->assertTrue(
2155 $status->getValue(),
2156 'Returns true if the email specified is the current email'
2158 $this->assertSame(
2159 $user->getEmail(),
2160 $startingEmail,
2161 'Email has not changed'
2166 * @covers User::isItemLoaded
2167 * @covers User::setItemLoaded
2169 public function testItemLoaded() {
2170 $user = User::newFromName( 'DannyS712' );
2171 $this->assertTrue(
2172 $user->isItemLoaded( 'name', 'only' ),
2173 'Users created by name have user names loaded'
2175 $this->assertFalse(
2176 $user->isItemLoaded( 'all', 'all' ),
2177 'Not everything is loaded yet'
2179 $user->load();
2180 $this->assertTrue(
2181 $user->isItemLoaded( 'FooBar', 'all' ),
2182 'All items now loaded'
2187 * @covers User::requiresHTTPS
2188 * @dataProvider provideRequiresHTTPS
2190 public function testRequiresHTTPS( $preference, $hook1, $hook2, bool $expected ) {
2191 $this->setMwGlobals( [
2192 'wgSecureLogin' => true,
2193 'wgForceHTTPS' => false,
2194 ] );
2196 $user = User::newFromName( 'UserWhoMayRequireHTTPS' );
2197 $user->setOption( 'prefershttps', $preference );
2198 $user->saveSettings();
2200 $this->filterDeprecated( '/UserRequiresHTTPS hook/' );
2201 $this->setTemporaryHook( 'UserRequiresHTTPS', function ( $user, &$https ) use ( $hook1 ) {
2202 $https = $hook1;
2203 return false;
2204 } );
2205 $this->filterDeprecated( '/CanIPUseHTTPS hook/' );
2206 $this->setTemporaryHook( 'CanIPUseHTTPS', function ( $ip, &$canDo ) use ( $hook2 ) {
2207 if ( $hook2 === 'notcalled' ) {
2208 $this->fail( 'CanIPUseHTTPS hook should not have been called' );
2210 $canDo = $hook2;
2211 return false;
2212 } );
2214 $user = User::newFromName( $user->getName() );
2215 $this->assertSame( $user->requiresHTTPS(), $expected );
2218 public static function provideRequiresHTTPS() {
2219 return [
2220 'Wants, hook requires, can' => [ true, true, true, true ],
2221 'Wants, hook requires, cannot' => [ true, true, false, false ],
2222 'Wants, hook prohibits, not called' => [ true, false, 'notcalled', false ],
2223 'Does not want, hook requires, can' => [ false, true, true, true ],
2224 'Does not want, hook requires, cannot' => [ false, true, false, false ],
2225 'Does not want, hook prohibits, not called' => [ false, false, 'notcalled', false ],
2230 * @covers User::requiresHTTPS
2232 public function testRequiresHTTPS_disabled() {
2233 $this->setMwGlobals( [
2234 'wgSecureLogin' => false,
2235 'wgForceHTTPS' => false,
2236 ] );
2238 $user = User::newFromName( 'UserWhoMayRequireHTTP' );
2239 $user->setOption( 'prefershttps', true );
2240 $user->saveSettings();
2242 $user = User::newFromName( $user->getName() );
2243 $this->assertFalse(
2244 $user->requiresHTTPS(),
2245 'User preference ignored if wgSecureLogin is false'
2250 * @covers User::requiresHTTPS
2252 public function testRequiresHTTPS_forced() {
2253 $this->setMwGlobals( [
2254 'wgSecureLogin' => true,
2255 'wgForceHTTPS' => true,
2256 ] );
2258 $user = User::newFromName( 'UserWhoMayRequireHTTP' );
2259 $user->setOption( 'prefershttps', false );
2260 $user->saveSettings();
2262 $user = User::newFromName( $user->getName() );
2263 $this->assertTrue(
2264 $user->requiresHTTPS(),
2265 'User preference ignored if wgForceHTTPS is true'
2270 * @covers User::isCreatableName
2272 public function testIsCreatableName() {
2273 $this->setMwGlobals( [
2274 'wgInvalidUsernameCharacters' => '@',
2275 ] );
2277 $longUserName = str_repeat( 'x', 260 );
2279 $this->assertFalse(
2280 User::isCreatableName( $longUserName ),
2281 'longUserName is too long'
2283 $this->assertFalse(
2284 User::isCreatableName( 'Foo@Bar' ),
2285 'User name contains invalid character'
2287 $this->assertTrue(
2288 User::isCreatableName( 'FooBar' ),
2289 'User names with no issues can be created'
2294 * @covers User::isUsableName
2296 public function testIsUsableName() {
2297 $this->setMwGlobals( [
2298 'wgReservedUsernames' => [
2299 'MediaWiki default',
2300 'msg:reserved-user'
2302 'wgForceUIMsgAsContentMsg' => [
2303 'reserved-user'
2305 ] );
2307 $this->assertFalse(
2308 User::isUsableName( '' ),
2309 'Only valid user names are creatable'
2311 $this->assertFalse(
2312 User::isUsableName( 'MediaWiki default' ),
2313 'Reserved names cannot be used'
2315 $this->assertFalse(
2316 User::isUsableName( 'reserved-user' ),
2317 'Names can also be reserved via msg: '
2319 $this->assertTrue(
2320 User::isUsableName( 'FooBar' ),
2321 'User names with no issues can be used'
2326 * @covers User::addToDatabase
2328 public function testAddToDatabase_bad() {
2329 $user = new User();
2330 $this->expectException( RuntimeException::class );
2331 $this->expectExceptionMessage(
2332 'User name field is not set.'
2334 $user->addToDatabase();
2338 * @covers User::pingLimiter
2340 public function testPingLimiterHook() {
2341 $this->setMwGlobals( [
2342 'wgRateLimits' => [
2343 'edit' => [
2344 'user' => [ 3, 60 ],
2347 ] );
2349 // Hook leaves $result false
2350 $this->setTemporaryHook(
2351 'PingLimiter',
2352 function ( &$user, $action, &$result, $incrBy ) {
2353 return false;
2356 $this->assertFalse(
2357 $this->user->pingLimiter(),
2358 'Hooks that just return false leave $result false'
2360 $this->removeTemporaryHook( 'PingLimiter' );
2362 // Hook sets $result to true
2363 $this->setTemporaryHook(
2364 'PingLimiter',
2365 function ( &$user, $action, &$result, $incrBy ) {
2366 $result = true;
2367 return false;
2370 $this->assertTrue(
2371 $this->user->pingLimiter(),
2372 'Hooks can set $result to true'
2374 $this->removeTemporaryHook( 'PingLimiter' );
2376 // Unknown action
2377 $this->assertFalse(
2378 $this->user->pingLimiter( 'FakeActionWithNoRateLimit' ),
2379 'Actions with no rate limit set do not trip the rate limiter'
2384 * @covers User::pingLimiter
2386 public function testPingLimiterWithStaleCache() {
2387 global $wgMainCacheType;
2389 $this->setMwGlobals( [
2390 'wgRateLimits' => [
2391 'edit' => [
2392 'user' => [ 1, 60 ],
2395 ] );
2397 $cacheTime = 1600000000.0;
2398 $appTime = 1600000000;
2399 $cache = new HashBagOStuff();
2401 // TODO: make the main object cache a service we can override, T243233
2402 ObjectCache::$instances[$wgMainCacheType] = $cache;
2404 $cache->setMockTime( $cacheTime ); // this is a reference!
2405 MWTimestamp::setFakeTime( function () use ( &$appTime ) {
2406 return (int)$appTime;
2407 } );
2409 $this->assertFalse( $this->user->pingLimiter(), 'limit not reached' );
2410 $this->assertTrue( $this->user->pingLimiter(), 'limit reached' );
2412 // Make it so that rate limits are expired according to MWTimestamp::time(),
2413 // but not according to $cache->getCurrentTime(), emulating the conditions
2414 // that trigger T246991.
2415 $cacheTime += 10;
2416 $appTime += 100;
2418 $this->assertFalse( $this->user->pingLimiter(), 'limit expired' );
2419 $this->assertTrue( $this->user->pingLimiter(), 'limit functional after expiry' );
2423 * @covers User::pingLimiter
2425 public function testPingLimiterRate() {
2426 global $wgMainCacheType;
2428 $this->setMwGlobals( [
2429 'wgRateLimits' => [
2430 'edit' => [
2431 'user' => [ 3, 60 ],
2434 ] );
2436 $fakeTime = 1600000000;
2437 $cache = new HashBagOStuff();
2439 // TODO: make the main object cache a service we can override, T243233
2440 ObjectCache::$instances[$wgMainCacheType] = $cache;
2442 $cache->setMockTime( $fakeTime ); // this is a reference!
2443 MWTimestamp::setFakeTime( function () use ( &$fakeTime ) {
2444 return (int)$fakeTime;
2445 } );
2447 // The limit is 3 per 60 second. Do 5 edits at an emulated 50 second interval.
2448 // They should all pass. This tests that the counter doesn't just keeps increasing
2449 // but gets reset in an appropriate way.
2450 $this->assertFalse( $this->user->pingLimiter(), 'first ping should pass' );
2452 $fakeTime += 50;
2453 $this->assertFalse( $this->user->pingLimiter(), 'second ping should pass' );
2455 $fakeTime += 50;
2456 $this->assertFalse( $this->user->pingLimiter(), 'third ping should pass' );
2458 $fakeTime += 50;
2459 $this->assertFalse( $this->user->pingLimiter(), 'fourth ping should pass' );
2461 $fakeTime += 50;
2462 $this->assertFalse( $this->user->pingLimiter(), 'fifth ping should pass' );
2465 private function newFakeUser( $name, $ip, $id ) {
2466 $req = new FauxRequest();
2467 $req->setIP( $ip );
2469 $user = User::newFromName( $name, false );
2471 $access = TestingAccessWrapper::newFromObject( $user );
2472 $access->mRequest = $req;
2473 $access->mId = $id;
2474 $access->mLoadedItems = true;
2476 $this->overrideUserPermissions( $user, [
2477 'noratelimit' => false,
2478 ] );
2480 return $user;
2483 private function newFakeAnon( $ip ) {
2484 return $this->newFakeUser( $ip, $ip, 0 );
2488 * @covers User::pingLimiter
2490 public function testPingLimiterGlobal() {
2491 $this->setMwGlobals( [
2492 'wgRateLimits' => [
2493 'edit' => [
2494 'anon' => [ 1, 60 ],
2496 'purge' => [
2497 'ip' => [ 1, 60 ],
2498 'subnet' => [ 1, 60 ],
2500 'rollback' => [
2501 'user' => [ 1, 60 ],
2503 'move' => [
2504 'user-global' => [ 1, 60 ],
2506 'delete' => [
2507 'ip-all' => [ 1, 60 ],
2508 'subnet-all' => [ 1, 60 ],
2511 ] );
2513 // Set up a fake cache for storing limits
2514 $cache = new HashBagOStuff( [ 'keyspace' => 'xwiki' ] );
2516 global $wgMainCacheType;
2517 ObjectCache::$instances[$wgMainCacheType] = $cache;
2519 $cacheAccess = TestingAccessWrapper::newFromObject( $cache );
2520 $cacheAccess->keyspace = 'xwiki';
2522 $this->installMockContralIdProvider();
2524 // Set up some fake users
2525 $anon1 = $this->newFakeAnon( '1.2.3.4' );
2526 $anon2 = $this->newFakeAnon( '1.2.3.8' );
2527 $anon3 = $this->newFakeAnon( '6.7.8.9' );
2528 $anon4 = $this->newFakeAnon( '6.7.8.1' );
2530 // The mock ContralIdProvider uses the local id MOD 10 as the global ID.
2531 // So Frank has global ID 11, and Jane has global ID 56.
2532 // Kara's global ID is 0, which means no global ID.
2533 $frankX1 = $this->newFakeUser( 'Frank', '1.2.3.4', 111 );
2534 $frankX2 = $this->newFakeUser( 'Frank', '1.2.3.8', 111 );
2535 $frankY1 = $this->newFakeUser( 'Frank', '1.2.3.4', 211 );
2536 $janeX1 = $this->newFakeUser( 'Jane', '1.2.3.4', 456 );
2537 $janeX3 = $this->newFakeUser( 'Jane', '6.7.8.9', 456 );
2538 $janeY1 = $this->newFakeUser( 'Jane', '1.2.3.4', 756 );
2539 $karaX1 = $this->newFakeUser( 'Kara', '5.5.5.5', 100 );
2540 $karaY1 = $this->newFakeUser( 'Kara', '5.5.5.5', 200 );
2542 // Test limits on wiki X
2543 $this->assertFalse( $anon1->pingLimiter( 'edit' ), 'First anon edit' );
2544 $this->assertTrue( $anon2->pingLimiter( 'edit' ), 'Second anon edit' );
2546 $this->assertFalse( $anon1->pingLimiter( 'purge' ), 'Anon purge' );
2547 $this->assertTrue( $anon1->pingLimiter( 'purge' ), 'Anon purge via same IP' );
2549 $this->assertFalse( $anon3->pingLimiter( 'purge' ), 'Anon purge via different subnet' );
2550 $this->assertTrue( $anon2->pingLimiter( 'purge' ), 'Anon purge via same subnet' );
2552 $this->assertFalse( $frankX1->pingLimiter( 'rollback' ), 'First rollback' );
2553 $this->assertTrue( $frankX2->pingLimiter( 'rollback' ), 'Second rollback via different IP' );
2554 $this->assertFalse( $janeX1->pingLimiter( 'rollback' ), 'Rlbk by different user, same IP' );
2556 $this->assertFalse( $frankX1->pingLimiter( 'move' ), 'First move' );
2557 $this->assertTrue( $frankX2->pingLimiter( 'move' ), 'Second move via different IP' );
2558 $this->assertFalse( $janeX1->pingLimiter( 'move' ), 'Move by different user, same IP' );
2559 $this->assertFalse( $karaX1->pingLimiter( 'move' ), 'Move by another user' );
2560 $this->assertTrue( $karaX1->pingLimiter( 'move' ), 'Second move by another user' );
2562 $this->assertFalse( $frankX1->pingLimiter( 'delete' ), 'First delete' );
2563 $this->assertTrue( $janeX1->pingLimiter( 'delete' ), 'Delete via same IP' );
2565 $this->assertTrue( $frankX2->pingLimiter( 'delete' ), 'Delete via same subnet' );
2566 $this->assertFalse( $janeX3->pingLimiter( 'delete' ), 'Delete via different subnet' );
2568 // Now test how limits carry over to wiki Y
2569 $cacheAccess->keyspace = 'ywiki';
2571 $this->assertFalse( $anon3->pingLimiter( 'edit' ), 'Anon edit on wiki Y' );
2572 $this->assertTrue( $anon4->pingLimiter( 'purge' ), 'Anon purge on wiki Y, same subnet' );
2573 $this->assertFalse( $frankY1->pingLimiter( 'rollback' ), 'Rollback on wiki Y, same name' );
2574 $this->assertTrue( $frankY1->pingLimiter( 'move' ), 'Move on wiki Y, same name' );
2575 $this->assertTrue( $janeY1->pingLimiter( 'move' ), 'Move on wiki Y, different user' );
2576 $this->assertTrue( $frankY1->pingLimiter( 'delete' ), 'Delete on wiki Y, same IP' );
2578 // For a user without a global ID, user-global acts as a local restriction
2579 $this->assertFalse( $karaY1->pingLimiter( 'move' ), 'Move by another user' );
2580 $this->assertTrue( $karaY1->pingLimiter( 'move' ), 'Second move by another user' );
2583 private function doTestNewTalk( User $user ) {
2584 $this->hideDeprecated( 'User::getNewtalk' );
2585 $this->hideDeprecated( 'User::setNewtalk' );
2586 $this->assertFalse( $user->getNewtalk(), 'Should be false before updated' );
2587 $user->setNewtalk( true );
2588 $this->assertTrue( $user->getNewtalk(), 'Should be true after updated' );
2589 $user->clearInstanceCache();
2590 $this->assertTrue( $user->getNewtalk(), 'Should be true after cache cleared' );
2591 $user->setNewtalk( false );
2592 $this->assertFalse( $user->getNewtalk(), 'Should be false after updated' );
2593 $user->clearInstanceCache();
2594 $this->assertFalse( $user->getNewtalk(), 'Should be false after cache cleared' );
2598 * @covers User::getNewtalk
2599 * @covers User::setNewtalk
2601 public function testNewtalkRegistered() {
2602 $this->doTestNewTalk( $this->getTestUser()->getUser() );
2606 * @covers User::getNewtalk
2607 * @covers User::setNewtalk
2609 public function testNewtalkAnon() {
2610 $this->doTestNewTalk( User::newFromName( __METHOD__ ) );
2614 * @covers User::getNewMessageLinks
2615 * @covers User::getNewMessageRevisionId
2617 public function testGetNewMessageLinks() {
2618 $this->hideDeprecated( 'User::getNewMessageLinks' );
2619 $this->hideDeprecated( 'User::getNewMessageRevisionId' );
2620 $this->hideDeprecated( 'User::setNewtalk' );
2621 $this->hideDeprecated( 'Revision::__construct' );
2622 $this->hideDeprecated( 'Revision::getId' );
2624 $user = $this->getTestUser()->getUser();
2625 $userTalk = $user->getTalkPage();
2627 // Make sure time progresses between revisions.
2628 // MediaWikiIntegrationTestCase automatically restores the real clock.
2629 $clock = MWTimestamp::time();
2630 MWTimestamp::setFakeTime( function () use ( &$clock ) {
2631 return ++$clock;
2632 } );
2634 $status = $this->editPage( $userTalk->getPrefixedText(), 'Message one' );
2635 $this->assertTrue( $status->isGood(), 'Sanity: create revision 1 of user talk' );
2636 /** @var RevisionRecord $firstRevRecord */
2637 $firstRevRecord = $status->getValue()['revision-record'];
2638 $status = $this->editPage( $userTalk->getPrefixedText(), 'Message two' );
2639 $this->assertTrue( $status->isGood(), 'Sanity: create revision 2 of user talk' );
2640 /** @var RevisionRecord $secondRevRecord */
2641 $secondRevRecord = $status->getValue()['revision-record'];
2643 $user->setNewtalk( true, $secondRevRecord );
2644 $links = $user->getNewMessageLinks();
2645 $this->assertTrue( count( $links ) > 0, 'Must have new message links' );
2646 $this->assertSame( $userTalk->getLocalURL(), $links[0]['link'] );
2647 $this->assertSame( $firstRevRecord->getId(), $links[0]['rev']->getId() );
2648 $this->assertSame( $firstRevRecord->getId(), $user->getNewMessageRevisionId() );
2651 private function installMockContralIdProvider() {
2652 $mockCentralIdLookup = $this->createNoOpMock(
2653 CentralIdLookup::class,
2654 [ 'centralIdFromLocalUser', 'getProviderId' ]
2657 $mockCentralIdLookup->method( 'centralIdFromLocalUser' )
2658 ->willReturnCallback( function ( User $user ) {
2659 return $user->getId() % 100;
2660 } );
2662 $this->setMwGlobals( [
2663 'wgCentralIdLookupProvider' => 'test',
2664 'wgCentralIdLookupProviders' => [
2665 'test' => [
2666 'factory' => function () use ( $mockCentralIdLookup ) {
2667 return $mockCentralIdLookup;
2671 ] );
2675 * @covers User::loadFromDatabase
2676 * @covers User::loadDefaults
2678 public function testBadUserID() {
2679 $user = User::newFromId( 999999999 );
2680 $this->assertSame( 'Unknown user', $user->getName() );
2684 * @covers User::probablyCan
2685 * @covers User::definitelyCan
2686 * @covers User::authorizeRead
2687 * @covers User::authorizeWrite
2689 public function testAuthorityMethods() {
2690 $user = $this->getTestUser()->getUser();
2691 $page = Title::makeTitle( NS_MAIN, 'Test' );
2692 $this->assertFalse( $user->probablyCan( 'create', $page ) );
2693 $this->assertFalse( $user->definitelyCan( 'create', $page ) );
2694 $this->assertFalse( $user->authorizeRead( 'create', $page ) );
2695 $this->assertFalse( $user->authorizeWrite( 'create', $page ) );
2697 $this->overrideUserPermissions( $user, 'createpage' );
2698 $this->assertTrue( $user->probablyCan( 'create', $page ) );
2699 $this->assertTrue( $user->definitelyCan( 'create', $page ) );
2700 $this->assertTrue( $user->authorizeRead( 'create', $page ) );
2701 $this->assertTrue( $user->authorizeWrite( 'create', $page ) );