From 23242d04d1dce27147ad31c26be6ac6a272a9f5e Mon Sep 17 00:00:00 2001 From: DannyS712 Date: Sun, 23 Feb 2020 23:52:44 +0000 Subject: [PATCH] Add a new UserNameUtils service This replaces User::isValidUserName, ::isUsableName, ::isCreatableName, ::getCanonicalName, and ::isIP. Unlike User::isIP, UserNameUtils::isIP will //not// return true for IPv6 ranges. UserNameUtils::isIPRange, like User::isIPRange, accepts a name and simply calls IPUtils::isValidRange. User::isValidUserName, ::isUsableName, ::isCreatableName, ::getCanonical, ::isIP, and ::isValidRange are all soft deprecated A follow up patch will add this to the release notes, to avoid merge conflicts. Bug: T245231 Bug: T239527 Change-Id: I46684bc492bb74b728ff102971f6cdd4d746a50a --- includes/MediaWikiServices.php | 9 + includes/ServiceWiring.php | 14 + includes/user/User.php | 150 ++----- includes/user/UserNameUtils.php | 331 ++++++++++++++ .../includes/user/UserNameUtilsTest.php | 418 ++++++++++++++++++ tests/phpunit/includes/user/UserTest.php | 9 +- 6 files changed, 802 insertions(+), 129 deletions(-) create mode 100644 includes/user/UserNameUtils.php create mode 100644 tests/phpunit/includes/user/UserNameUtilsTest.php diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index f4dfd3f3624..79ab18a394a 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -59,6 +59,7 @@ use MediaWiki\Storage\BlobStoreFactory; use MediaWiki\Storage\NameTableStore; use MediaWiki\Storage\NameTableStoreFactory; use MediaWiki\Storage\PageEditStash; +use MediaWiki\User\UserNameUtils; use MessageCache; use MimeAnalyzer; use MWException; @@ -1187,6 +1188,14 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'UploadRevisionImporter' ); } + /** + * @since 1.35 + * @return UserNameUtils + */ + public function getUserNameUtils() : UserNameUtils { + return $this->getService( 'UserNameUtils' ); + } + /** * @since 1.28 * @return VirtualRESTServiceClient diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index be920b84929..dd6dca63609 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -89,6 +89,7 @@ use MediaWiki\Storage\BlobStoreFactory; use MediaWiki\Storage\NameTableStoreFactory; use MediaWiki\Storage\PageEditStash; use MediaWiki\Storage\SqlBlobStore; +use MediaWiki\User\UserNameUtils; use Wikimedia\DependencyStore\KeyValueDependencyStore; use Wikimedia\DependencyStore\SqlModuleDependencyStore; use Wikimedia\Message\IMessageFormatterFactory; @@ -1056,6 +1057,19 @@ return [ ); }, + 'UserNameUtils' => function ( MediaWikiServices $services ) : UserNameUtils { + // TODO there should be a proper injectable MessageLocalizer service (T247127) + return new UserNameUtils( + new ServiceOptions( + UserNameUtils::CONSTRUCTOR_OPTIONS, $services->getMainConfig() + ), + $services->getContentLanguage(), + LoggerFactory::getInstance( 'UserNameUtils' ), + $services->getService( 'TitleFactory' ), + RequestContext::getMain() + ); + }, + 'VirtualRESTServiceClient' => function ( MediaWikiServices $services ) : VirtualRESTServiceClient { $config = $services->getMainConfig()->get( 'VirtualRestConfig' ); diff --git a/includes/user/User.php b/includes/user/User.php index 6cc8164e7e3..bf450fe4b4e 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -31,6 +31,7 @@ use MediaWiki\MediaWikiServices; use MediaWiki\Session\SessionManager; use MediaWiki\Session\Token; use MediaWiki\User\UserIdentity; +use MediaWiki\User\UserNameUtils; use Wikimedia\Assert\Assert; use Wikimedia\IPSet; use Wikimedia\IPUtils; @@ -111,9 +112,6 @@ class User implements IDBAccessObject, UserIdentity { 'mActorId', ]; - /** @var string[]|false Cache for self::isUsableName() */ - private static $reservedUsernames = false; - /** Cache variables */ // @{ /** @var int */ @@ -942,6 +940,8 @@ class User implements IDBAccessObject, UserIdentity { * addresses like this, if we allowed accounts like this to be created * new users could get the old edits of these anonymous users. * + * @deprecated since 1.35, use the UserNameUtils service + * Note that UserNameUtils::isIP does not accept IPv6 ranges, while this method does * @param string $name Name to match * @return bool */ @@ -953,6 +953,7 @@ class User implements IDBAccessObject, UserIdentity { /** * Is the user an IP range? * + * @deprecated since 1.35, use the UserNameUtils service or IPUtils directly * @since 1.30 * @return bool */ @@ -968,45 +969,12 @@ class User implements IDBAccessObject, UserIdentity { * is longer than the maximum allowed username size or doesn't begin with * a capital letter. * + * @deprecated since 1.35, use the UserNameUtils service * @param string $name Name to match * @return bool */ public static function isValidUserName( $name ) { - global $wgMaxNameChars; - - if ( $name == '' - || self::isIP( $name ) - || strpos( $name, '/' ) !== false - || strlen( $name ) > $wgMaxNameChars - || $name != MediaWikiServices::getInstance()->getContentLanguage()->ucfirst( $name ) - ) { - return false; - } - - // Ensure that the name can't be misresolved as a different title, - // such as with extra namespace keys at the start. - $parsed = Title::newFromText( $name ); - if ( $parsed === null - || $parsed->getNamespace() - || strcmp( $name, $parsed->getPrefixedText() ) ) { - return false; - } - - // Check an additional blacklist of troublemaker characters. - // Should these be merged into the title char list? - $unicodeBlacklist = '/[' . - '\x{0080}-\x{009f}' . # iso-8859-1 control chars - '\x{00a0}' . # non-breaking space - '\x{2000}-\x{200f}' . # various whitespace - '\x{2028}-\x{202f}' . # breaks and control chars - '\x{3000}' . # ideographic space - '\x{e000}-\x{f8ff}' . # private use - ']/u'; - if ( preg_match( $unicodeBlacklist, $name ) ) { - return false; - } - - return true; + return MediaWikiServices::getInstance()->getUserNameUtils()->isValid( $name ); } /** @@ -1017,31 +985,12 @@ class User implements IDBAccessObject, UserIdentity { * If an account already exists in this form, login will be blocked * by a failure to pass this function. * + * @deprecated since 1.35, use the UserNameUtils service * @param string $name Name to match * @return bool */ public static function isUsableName( $name ) { - global $wgReservedUsernames; - // Must be a valid username, obviously ;) - if ( !self::isValidUserName( $name ) ) { - return false; - } - - if ( !self::$reservedUsernames ) { - self::$reservedUsernames = $wgReservedUsernames; - Hooks::run( 'UserGetReservedNames', [ &self::$reservedUsernames ] ); - } - - // Certain names may be reserved for batch processes. - foreach ( self::$reservedUsernames as $reserved ) { - if ( substr( $reserved, 0, 4 ) == 'msg:' ) { - $reserved = wfMessage( substr( $reserved, 4 ) )->inContentLanguage()->plain(); - } - if ( $reserved == $name ) { - return false; - } - } - return true; + return MediaWikiServices::getInstance()->getUserNameUtils()->isUsable( $name ); } /** @@ -1091,31 +1040,12 @@ class User implements IDBAccessObject, UserIdentity { * Additional blacklisting may be added here rather than in * isValidUserName() to avoid disrupting existing accounts. * + * @deprecated since 1.35, use the UserNameUtils service * @param string $name String to match * @return bool */ public static function isCreatableName( $name ) { - global $wgInvalidUsernameCharacters; - - // Ensure that the username isn't longer than 235 bytes, so that - // (at least for the builtin skins) user javascript and css files - // will work. (T25080) - if ( strlen( $name ) > 235 ) { - wfDebugLog( 'username', __METHOD__ . - ": '$name' invalid due to length" ); - return false; - } - - // Preg yells if you try to give it an empty string - if ( $wgInvalidUsernameCharacters !== '' && - preg_match( '/[' . preg_quote( $wgInvalidUsernameCharacters, '/' ) . ']/', $name ) - ) { - wfDebugLog( 'username', __METHOD__ . - ": '$name' invalid due to wgInvalidUsernameCharacters" ); - return false; - } - - return self::isUsableName( $name ); + return MediaWikiServices::getInstance()->getUserNameUtils()->isCreatable( $name ); } /** @@ -1182,6 +1112,8 @@ class User implements IDBAccessObject, UserIdentity { /** * Given unvalidated user input, return a canonical username, or false if * the username is invalid. + * + * @deprecated since 1.35, use the UserNameUtils service * @param string $name User input * @param string|bool $validate Type of validation to use: * - false No validation @@ -1193,50 +1125,26 @@ class User implements IDBAccessObject, UserIdentity { * @return bool|string */ public static function getCanonicalName( $name, $validate = 'valid' ) { - // Force usernames to capital - $name = MediaWikiServices::getInstance()->getContentLanguage()->ucfirst( $name ); + // Backwards compatibility with strings / false + $validationLevels = [ + 'valid' => UserNameUtils::RIGOR_VALID, + 'usable' => UserNameUtils::RIGOR_USABLE, + 'creatable' => UserNameUtils::RIGOR_CREATABLE + ]; - # Reject names containing '#'; these will be cleaned up - # with title normalisation, but then it's too late to - # check elsewhere - if ( strpos( $name, '#' ) !== false ) { - return false; + if ( $validate === false ) { + $validation = UserNameUtils::RIGOR_NONE; + } elseif ( array_key_exists( $validate, $validationLevels ) ) { + $validation = $validationLevels[ $validate ]; + } else { + // Not a recognized value, probably a test for unsupported validation + // levels, regardless, just pass it along + $validation = $validate; } - // Clean up name according to title rules, - // but only when validation is requested (T14654) - $t = ( $validate !== false ) ? - Title::newFromText( $name, NS_USER ) : Title::makeTitle( NS_USER, $name ); - // Check for invalid titles - if ( $t === null || $t->getNamespace() !== NS_USER || $t->isExternal() ) { - return false; - } - - $name = $t->getText(); - - switch ( $validate ) { - case false: - break; - case 'valid': - if ( !self::isValidUserName( $name ) ) { - $name = false; - } - break; - case 'usable': - if ( !self::isUsableName( $name ) ) { - $name = false; - } - break; - case 'creatable': - if ( !self::isCreatableName( $name ) ) { - $name = false; - } - break; - default: - throw new InvalidArgumentException( - 'Invalid parameter value for $validate in ' . __METHOD__ ); - } - return $name; + return MediaWikiServices::getInstance() + ->getUserNameUtils() + ->getCanonical( (string)$name, $validation ); } /** diff --git a/includes/user/UserNameUtils.php b/includes/user/UserNameUtils.php new file mode 100644 index 00000000000..343ff823178 --- /dev/null +++ b/includes/user/UserNameUtils.php @@ -0,0 +1,331 @@ +assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); + $this->options = $options; + $this->contentLang = $contentLang; + $this->logger = $logger; + $this->titleFactory = $titleFactory; + $this->msgLocalizer = $msgLocalizer; + } + + /** + * Is the input a valid username? + * + * Checks if the input is a valid username, we don't want an empty string, + * an IP address, anything that contains slashes (would mess up subpages), + * is longer than the maximum allowed username size or doesn't begin with + * a capital letter. + * + * @param string $name Name to match + * @return bool + */ + public function isValid( string $name ) : bool { + if ( $name === '' + || $this->isIP( $name ) + || strpos( $name, '/' ) !== false + || strlen( $name ) > $this->options->get( 'MaxNameChars' ) + || $name !== $this->contentLang->ucfirst( $name ) + ) { + return false; + } + + // Ensure that the name can't be misresolved as a different title, + // such as with extra namespace keys at the start. + $title = $this->titleFactory->newFromText( $name ); + if ( $title === null + || $title->getNamespace() + || strcmp( $name, $title->getPrefixedText() ) + ) { + return false; + } + + // Check an additional blacklist of troublemaker characters. + // Should these be merged into the title char list? + $unicodeBlacklist = '/[' . + '\x{0080}-\x{009f}' . # iso-8859-1 control chars + '\x{00a0}' . # non-breaking space + '\x{2000}-\x{200f}' . # various whitespace + '\x{2028}-\x{202f}' . # breaks and control chars + '\x{3000}' . # ideographic space + '\x{e000}-\x{f8ff}' . # private use + ']/u'; + if ( preg_match( $unicodeBlacklist, $name ) ) { + return false; + } + + return true; + } + + /** + * Usernames which fail to pass this function will be blocked + * from user login and new account registrations, but may be used + * internally by batch processes. + * + * If an account already exists in this form, login will be blocked + * by a failure to pass this function. + * + * @param string $name Name to match + * @return bool + */ + public function isUsable( string $name ) : bool { + // Must be a valid username, obviously ;) + if ( !$this->isValid( $name ) ) { + return false; + } + + if ( !$this->reservedUsernames ) { + $reservedUsernames = $this->options->get( 'ReservedUsernames' ); + Hooks::run( 'UserGetReservedNames', [ &$reservedUsernames ] ); + $this->reservedUsernames = $reservedUsernames; + } + + // Certain names may be reserved for batch processes. + foreach ( $this->reservedUsernames as $reserved ) { + if ( substr( $reserved, 0, 4 ) === 'msg:' ) { + $reserved = $this->msgLocalizer + ->msg( substr( $reserved, 4 ) ) + ->inContentLanguage() + ->plain(); + } + if ( $reserved === $name ) { + return false; + } + } + return true; + } + + /** + * Usernames which fail to pass this function will be blocked + * from new account registrations, but may be used internally + * either by batch processes or by user accounts which have + * already been created. + * + * Additional blacklisting may be added here rather than in + * isValidUserName() to avoid disrupting existing accounts. + * + * @param string $name String to match + * @return bool + */ + public function isCreatable( string $name ) : bool { + // Ensure that the username isn't longer than 235 bytes, so that + // (at least for the builtin skins) user javascript and css files + // will work. (T25080) + if ( strlen( $name ) > 235 ) { + $this->logger->debug( + __METHOD__ . ": '$name' uncreatable due to length" + ); + return false; + } + + $invalid = $this->options->get( 'InvalidUsernameCharacters' ); + // Preg yells if you try to give it an empty string + if ( $invalid !== '' && + preg_match( '/[' . preg_quote( $invalid, '/' ) . ']/', $name ) + ) { + $this->logger->debug( + __METHOD__ . ": '$name' uncreatable due to wgInvalidUsernameCharacters" + ); + return false; + } + + return $this->isUsable( $name ); + } + + /** + * Given unvalidated user input, return a canonical username, or false if + * the username is invalid. + * @param string $name User input + * @param string $validate Type of validation to use + * Use of public constants RIGOR_* is preferred + * - RIGOR_NONE No validation + * - RIGOR_VALID Valid for batch processes + * - RIGOR_USABLE Valid for batch processes and login + * - RIGOR_CREATABLE Valid for batch processes, login and account creation + * + * @throws InvalidArgumentException + * @return bool|string + */ + public function getCanonical( string $name, string $validate = self::RIGOR_VALID ) { + // Force usernames to capital + $name = $this->contentLang->ucfirst( $name ); + + // Reject names containing '#'; these will be cleaned up + // with title normalisation, but then it's too late to + // check elsewhere + if ( strpos( $name, '#' ) !== false ) { + return false; + } + + // No need to proceed if no validation is requested, just + // clean up underscores and return + if ( $validate === self::RIGOR_NONE ) { + $name = strtr( $name, '_', ' ' ); + return $name; + } + + // Clean up name according to title rules, + // but only when validation is requested (T14654) + $title = $this->titleFactory->newFromText( $name, NS_USER ); + + // Check for invalid titles + if ( $title === null + || $title->getNamespace() !== NS_USER + || $title->isExternal() + ) { + return false; + } + + $name = $title->getText(); + + // RIGOR_NONE handled above + switch ( $validate ) { + case self::RIGOR_VALID: + if ( !$this->isValid( $name ) ) { + return false; + } + return $name; + case self::RIGOR_USABLE: + if ( !$this->isUsable( $name ) ) { + return false; + } + return $name; + case self::RIGOR_CREATABLE: + if ( !$this->isCreatable( $name ) ) { + return false; + } + return $name; + default: + throw new InvalidArgumentException( + "Invalid parameter value for validation ($validate) in " . + __METHOD__ + ); + } + } + + /** + * Does the string match an anonymous IP address? + * + * This function exists for username validation, in order to reject + * usernames which are similar in form to IP addresses. Strings such + * as 300.300.300.300 will return true because it looks like an IP + * address, despite not being strictly valid. + * + * We match "\d{1,3}\.\d{1,3}\.\d{1,3}\.xxx" as an anonymous IP + * address because the usemod software would "cloak" anonymous IP + * addresses like this, if we allowed accounts like this to be created + * new users could get the old edits of these anonymous users. + * + * Unlike User::isIP, this does //not// match IPv6 ranges (T239527) + * + * @param string $name Name to check + * @return bool + */ + public function isIP( string $name ) : bool { + $anyIPv4 = '/^\d{1,3}\.\d{1,3}\.\d{1,3}\.(?:xxx|\d{1,3})$/'; + $validIP = IPUtils::isValid( $name ); + return $validIP || preg_match( $anyIPv4, $name ); + } + + /** + * Wrapper for IPUtils::isValidRange + * + * @param string $range Range to check + * @return bool + */ + public function isValidIPRange( string $range ) : bool { + return IPUtils::isValidRange( $range ); + } + +} diff --git a/tests/phpunit/includes/user/UserNameUtilsTest.php b/tests/phpunit/includes/user/UserNameUtilsTest.php new file mode 100644 index 00000000000..a6b4f57afe1 --- /dev/null +++ b/tests/phpunit/includes/user/UserNameUtilsTest.php @@ -0,0 +1,418 @@ +getMockBuilder( Language::class ) + ->disableOriginalConstructor() + ->getMock(); + $contentLang->method( 'ucfirst' ) + ->willReturnCallback( function ( $str ) { + return ucfirst( $str ); + } ); + return $contentLang; + } + + private function getUtils( + array $options = [], + Language $contentLang = null, + LoggerInterface $logger = null, + MessageLocalizer $msgLocalizer = null + ) { + $baseOptions = [ + 'MaxNameChars' => 255, + 'ReservedUsernames' => [ + 'MediaWiki default' + ], + 'InvalidUsernameCharacters' => '@:' + ]; + $config = $options + $baseOptions; + $serviceOptions = new ServiceOptions( UserNameUtils::CONSTRUCTOR_OPTIONS, $config ); + + if ( $contentLang === null ) { + $contentLang = $this->createMock( Language::class ); + } + + if ( $logger === null ) { + $logger = new NullLogger(); + } + + // It is almost impossible to mock the TitleFactory, so relying on the real + // one. Once its possible to mock, this should be converted to a unit test. + $titleFactory = new TitleFactory(); + + if ( $msgLocalizer === null ) { + $msgLocalizer = $this->createMock( MessageLocalizer::class ); + } + + $utils = new UserNameUtils( + $serviceOptions, + $contentLang, + $logger, + $titleFactory, + $msgLocalizer + ); + return $utils; + } + + /** + * @dataProvider provideIsValid + * @covers MediaWiki\User\UserNameUtils::isValid + */ + public function testIsValid( string $name, bool $result ) { + $utils = $this->getUtils( + [], + $this->getUCFirstLanguageMock() + ); + $this->assertSame( + $result, + $utils->isValid( $name ) + ); + } + + public function provideIsValid() { + return [ + 'Empty string' => [ '', false ], + 'Blank space' => [ ' ', false ], + 'Starts with small letter' => [ 'abcd', false ], + 'Contains slash' => [ 'Ab/cd', false ], + 'Whitespace' => [ 'Ab cd', true ], + 'IP' => [ '192.168.1.1', false ], + 'IP range' => [ '116.17.184.5/32', false ], + 'IPv6 range' => [ '::e:f:2001/96', false ], + 'Reserved Namespace' => [ 'User:Abcd', false ], + 'Starts with Numbers' => [ '12abcd232', true ], + 'Start with ? mark' => [ '?abcd', true ], + 'Start with #' => [ '#abcd', false ], + ' Mixed scripts' => [ 'Abcdകഖഗഘ', true ], + 'ZWNJ- Format control character' => [ 'ജോസ്‌തോമസ്', false ], + ' Ideographic space' => [ 'Ab cd', false ], + 'Looks too much like an IPv4 address (1)' => [ '300.300.300.300', false ], + 'Looks too much like an IPv4 address (2)' => [ '302.113.311.900', false ], + 'Reserved for usage by UseMod for cloaked logged-out users' => [ '203.0.113.xxx', false ], + 'Blacklisted characters' => [ "\u{E000}", false ], + ]; + } + + /** + * @dataProvider provideIsUsable + * @covers MediaWiki\User\UserNameUtils::isUsable + */ + public function testIsUsable( string $name, bool $result ) { + $msg = $this->getMockBuilder( Message::class ) + ->setMethods( [ 'inContentLanguage', 'plain' ] ) + ->disableOriginalConstructor() + ->getMock(); + $msg->method( 'inContentLanguage' ) + ->will( $this->returnSelf() ); + $msg->method( 'plain' ) + ->willReturn( 'reserved-user' ); + + $msgLocalizer = $this->getMockBuilder( MessageLocalizer::class ) + ->setMethods( [ 'msg' ] ) + ->getMock(); + $msgLocalizer->method( 'msg' ) + ->with( $this->equalTo( 'reserved-user' ) ) + ->willReturn( $msg ); + + $utils = $this->getUtils( + [ + 'ReservedUsernames' => [ + 'MediaWiki default', + 'msg:reserved-user' + ], + ], + $this->getUCFirstLanguageMock(), + null, + $msgLocalizer + ); + $this->assertSame( + $result, + $utils->isUsable( $name ) + ); + } + + public function provideIsUsable() { + return [ + 'Only valid user names are creatable' => [ '', false ], + 'Reserved names cannot be used' => [ 'MediaWiki default', false ], + 'Names can also be reserved via msg: ' => [ 'reserved-user', false ], + 'User names with no issues can be used' => [ 'FooBar', true ], + ]; + } + + /** + * @covers MediaWiki\User\UserNameUtils::isCreatable + */ + public function testIsCreatable() { + $logger = new TestLogger( true, function ( $message ) { + $message = str_replace( + 'MediaWiki\\User\\UserNameUtils::isCreatable: ', + '', + $message + ); + return $message; + } ); + $utils = $this->getUtils( + [], + $this->getUCFirstLanguageMock(), + $logger + ); + + $longUserName = str_repeat( 'q', 1000 ); + $this->assertFalse( + $utils->isCreatable( $longUserName ), + 'longUserName is too long' + ); + $this->assertSame( [ + [ LogLevel::DEBUG, "'$longUserName' uncreatable due to length" ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $atUserName = 'Foo@Bar'; + $this->assertFalse( + $utils->isCreatable( $atUserName ), + 'User name contains invalid character' + ); + $this->assertSame( [ + [ LogLevel::DEBUG, "'$atUserName' uncreatable due to wgInvalidUsernameCharacters" ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $this->assertTrue( + $utils->isCreatable( 'FooBar' ), + 'User names with no issues can be created' + ); + } + + /** + * @dataProvider provideGetCanonical + * @covers MediaWiki\User\UserNameUtils::getCanonical + */ + public function testGetCanonical( string $name, array $expectedArray ) { + $utils = $this->getUtils( + [], + $this->getUCFirstLanguageMock() + ); + foreach ( $expectedArray as $validate => $expected ) { + $this->assertSame( + $expected, + $utils->getCanonical( $name, $validate ), + "Validating '$name' with level '$validate' should be '$expected'" + ); + } + } + + public static function provideGetCanonical() { + return [ + 'Normal name' => [ + 'Normal name', + [ + UserNameUtils::RIGOR_CREATABLE => 'Normal name', + UserNameUtils::RIGOR_USABLE => 'Normal name', + UserNameUtils::RIGOR_VALID => 'Normal name', + UserNameUtils::RIGOR_NONE => 'Normal name' + ] + ], + 'Leading space' => [ + ' Leading space', + [ UserNameUtils::RIGOR_CREATABLE => 'Leading space' ] + ], + 'Trailing space' => [ + 'Trailing space ', + [ UserNameUtils::RIGOR_CREATABLE => 'Trailing space' ] + ], + 'Namespace prefix' => [ + 'Talk:Username', + [ + UserNameUtils::RIGOR_CREATABLE => false, + UserNameUtils::RIGOR_USABLE => false, + UserNameUtils::RIGOR_VALID => false, + UserNameUtils::RIGOR_NONE => 'Talk:Username' + ] + ], + 'With hash' => [ + 'name with # hash', + [ + UserNameUtils::RIGOR_CREATABLE => false, + UserNameUtils::RIGOR_USABLE => false + ] + ], + 'Multi spaces' => [ + 'Multi spaces', + [ + UserNameUtils::RIGOR_CREATABLE => 'Multi spaces', + UserNameUtils::RIGOR_USABLE => 'Multi spaces' + ] + ], + 'Lowercase' => [ + 'lowercase', + [ UserNameUtils::RIGOR_CREATABLE => 'Lowercase' ] + ], + 'Invalid character' => [ + 'in[]valid', + [ + UserNameUtils::RIGOR_CREATABLE => false, + UserNameUtils::RIGOR_USABLE => false, + UserNameUtils::RIGOR_VALID => false, + UserNameUtils::RIGOR_NONE => 'In[]valid' + ] + ], + 'With slash' => [ + 'with / slash', + [ + UserNameUtils::RIGOR_CREATABLE => false, + UserNameUtils::RIGOR_USABLE => false, + UserNameUtils::RIGOR_VALID => false, + UserNameUtils::RIGOR_NONE => 'With / slash' + ] + ], + ]; + } + + /** + * @covers MediaWiki\User\UserNameUtils::getCanonical + */ + public function testGetCanonical_interwiki() { + // fake interwiki map for the 'Interwiki prefix' testcase + $this->setTemporaryHook( + 'InterwikiLoadPrefix', + function ( $prefix, &$iwdata ) { + if ( $prefix === 'interwiki' ) { + $iwdata = [ + 'iw_url' => 'http://example.com/', + 'iw_local' => 0, + 'iw_trans' => 0, + ]; + return false; + } + } + ); + + $utils = $this->getUtils( + [], + $this->getUCFirstLanguageMock() + ); + + $name = 'interwiki:Username'; + $this->assertFalse( + $utils->getCanonical( + $name, + UserNameUtils::RIGOR_CREATABLE + ), + "'$name' is not creatable" + ); + $this->assertFalse( + $utils->getCanonical( + $name, + UserNameUtils::RIGOR_USABLE + ), + "'$name' is not usable" + ); + $this->assertFalse( + $utils->getCanonical( + $name, + UserNameUtils::RIGOR_VALID + ), + "'$name' is not valid" + ); + $this->assertSame( + 'Interwiki:Username', + $utils->getCanonical( $name, UserNameUtils::RIGOR_NONE ) + ); + } + + /** + * @covers MediaWiki\User\UserNameUtils::getCanonical + */ + public function testGetCanonical_bad() { + // Only ucfirst is called + $utils = $this->getUtils( + [], + $this->getUCFirstLanguageMock() + ); + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid parameter value for validation' ); + $utils->getCanonical( 'ValidName', 'InvalidValidationValue' ); + } + + /** + * @dataProvider provideIPs + * @covers MediaWiki\User\UserNameUtils::isIP + */ + public function testIsIP( string $value, bool $result ) { + $utils = $this->getUtils(); + $this->assertSame( + $result, + $utils->isIP( $value ) + ); + } + + public function provideIPs() { + return [ + 'Empty string' => [ '', false ], + 'Blank space' => [ ' ', false ], + 'IPv4 private 10/8 (1)' => [ '10.0.0.0', true ], + 'IPv4 private 10/8 (2)' => [ '10.255.255.255', true ], + 'IPv4 private 192.168/16' => [ '192.168.1.1', true ], + 'IPv4 example' => [ '203.0.113.0', true ], + 'IPv6 example' => [ '2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff', true ], + // Not valid IPs but classified as such by MediaWiki for negated asserting + // of whether this might be the identifier of a logged-out user or whether + // to allow usernames like it. + 'Looks too much like an IPv4 address' => [ '300.300.300.300', true ], + 'Assigned by UseMod to cloaked logged-out users' => [ '203.0.113.xxx', true ], + 'Does not accept IPv4 ranges' => [ '74.24.52.13/20', false ], + 'Does not accept IPv6 ranges' => [ 'fc:100:a:d:1:e:ac:0/24', false ], + ]; + } + + /** + * @dataProvider provideIPRanges + * @covers MediaWiki\User\UserNameUtils::isValidIPRange + */ + public function testIsValidIPRange( $value, $result ) { + $utils = $this->getUtils(); + $this->assertSame( + $result, + $utils->isValidIPRange( $value ) + ); + } + + public function provideIPRanges() { + return [ + [ '116.17.184.5/32', true ], + [ '0.17.184.5/30', true ], + [ '16.17.184.1/24', true ], + [ '30.242.52.14/1', true ], + [ '10.232.52.13/8', true ], + [ '30.242.52.14/0', true ], + [ '::e:f:2001/96', true ], + [ '::c:f:2001/128', true ], + [ '::10:f:2001/70', true ], + [ '::fe:f:2001/1', true ], + [ '::6d:f:2001/8', true ], + [ '::fe:f:2001/0', true ], + [ '116.17.184.5/33', false ], + [ '0.17.184.5/130', false ], + [ '16.17.184.1/-1', false ], + [ '10.232.52.13/*', false ], + [ '7.232.52.13/ab', false ], + [ '11.232.52.13/', false ], + [ '::e:f:2001/129', false ], + [ '::c:f:2001/228', false ], + [ '::10:f:2001/-1', false ], + [ '::6d:f:2001/*', false ], + [ '::86:f:2001/ab', false ], + [ '::23:f:2001/', false ] + ]; + } + +} diff --git a/tests/phpunit/includes/user/UserTest.php b/tests/phpunit/includes/user/UserTest.php index 6ff722adba0..867acf69692 100644 --- a/tests/phpunit/includes/user/UserTest.php +++ b/tests/phpunit/includes/user/UserTest.php @@ -37,13 +37,6 @@ class UserTest extends MediaWikiTestCase { $this->setUpPermissionGlobals(); $this->user = $this->getTestUser( 'unittesters' )->getUser(); - - TestingAccessWrapper::newFromClass( User::class )->reservedUsernames = false; - } - - protected function tearDown() : void { - parent::tearDown(); - TestingAccessWrapper::newFromClass( User::class )->reservedUsernames = false; } private function setUpPermissionGlobals() { @@ -685,7 +678,7 @@ class UserTest extends MediaWikiTestCase { public function testGetCanonicalName_bad() { $this->expectException( InvalidArgumentException::class ); $this->expectExceptionMessage( - 'Invalid parameter value for $validate in User::getCanonicalName' + 'Invalid parameter value for validation' ); User::getCanonicalName( 'ValidName', 'InvalidValidationValue' ); }