Merge "Disable flaky Selenium test" into REL1_34
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Tue, 8 Oct 2019 21:11:31 +0000 (21:11 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Tue, 8 Oct 2019 21:11:31 +0000 (21:11 +0000)
22 files changed:
RELEASE-NOTES-1.34
includes/DefaultSettings.php
includes/MediaWikiServices.php
includes/ServiceWiring.php
includes/cache/localisation/LocalisationCache.php
includes/installer/Installer.php
includes/preferences/DefaultPreferencesFactory.php
includes/user/PasswordReset.php
languages/Language.php
languages/i18n/en.json
languages/i18n/qqq.json
maintenance/rebuildLocalisationCache.php
tests/common/TestSetup.php
tests/common/TestsAutoLoader.php
tests/phpunit/MediaWikiIntegrationTestCase.php
tests/phpunit/includes/TestLocalisationCache.php [new file with mode: 0644]
tests/phpunit/includes/cache/LocalisationCacheTest.php
tests/phpunit/includes/logging/LogFormatterTest.php
tests/phpunit/includes/preferences/DefaultPreferencesFactoryTest.php
tests/phpunit/includes/user/PasswordResetTest.php
tests/phpunit/languages/LanguageTest.php
tests/phpunit/unit/includes/resourceloader/ResourceLoaderImageTest.php

index 6dc0d66..71d1a41 100644 (file)
@@ -458,6 +458,8 @@ because of Phabricator reports.
 * User::setNewpassword(), deprecated in 1.27 has been removed.
 * The ObjectCache::getMainWANInstance and ObjectCache::getMainStashInstance
   functions, deprecated since 1.28, have been removed.
+* Language::$dataCache has been removed (without prior deprecation, for
+  practical reasons). Use MediaWikiServices instead to get a LocalisationCache.
 
 === Deprecations in 1.34 ===
 * The MWNamespace class is deprecated. Use NamespaceInfo.
@@ -602,6 +604,8 @@ because of Phabricator reports.
 * ApiQueryBase::showHiddenUsersAddBlockInfo() is deprecated. Use
   ApiQueryBlockInfoTrait instead.
 * PasswordReset is now a service, its direct instantiation is deprecated.
+* Language::getLocalisationCache() is deprecated. Use MediaWikiServices
+  instead.
 
 === Other changes in 1.34 ===
 * Added option to specify "Various authors" as author in extension credits using
index 3ba240a..8449073 100644 (file)
@@ -2627,6 +2627,8 @@ $wgLocalisationCacheConf = [
        'store' => 'detect',
        'storeClass' => false,
        'storeDirectory' => false,
+       'storeServer' => [],
+       'forceRecache' => false,
        'manualRecache' => false,
 ];
 
index f1fa892..a32fbef 100644 (file)
@@ -14,6 +14,7 @@ use GlobalVarConfig;
 use Hooks;
 use IBufferingStatsdDataFactory;
 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
+use LocalisationCache;
 use MediaWiki\Block\BlockManager;
 use MediaWiki\Block\BlockRestrictionStore;
 use MediaWiki\FileBackend\FSFile\TempFSFileFactory;
@@ -653,6 +654,14 @@ class MediaWikiServices extends ServiceContainer {
                return $this->getService( 'LinkRendererFactory' );
        }
 
+       /**
+        * @since 1.34
+        * @return LocalisationCache
+        */
+       public function getLocalisationCache() : LocalisationCache {
+               return $this->getService( 'LocalisationCache' );
+       }
+
        /**
         * @since 1.28
         * @return \BagOStuff
index 6d3fe6c..f6d0ec0 100644 (file)
@@ -287,6 +287,36 @@ return [
                );
        },
 
+       'LocalisationCache' => function ( MediaWikiServices $services ) : LocalisationCache {
+               $conf = $services->getMainConfig()->get( 'LocalisationCacheConf' );
+
+               $logger = LoggerFactory::getInstance( 'localisation' );
+
+               $store = LocalisationCache::getStoreFromConf(
+                       $conf, $services->getMainConfig()->get( 'CacheDirectory' ) );
+               $logger->debug( 'LocalisationCache: using store ' . get_class( $store ) );
+
+               return new $conf['class'](
+                       new ServiceOptions(
+                               LocalisationCache::CONSTRUCTOR_OPTIONS,
+                               // Two of the options are stored in $wgLocalisationCacheConf
+                               $conf,
+                               // In case someone set that config variable and didn't reset all keys, set defaults.
+                               [
+                                       'forceRecache' => false,
+                                       'manualRecache' => false,
+                               ],
+                               // Some other options come from config itself
+                               $services->getMainConfig()
+                       ),
+                       $store,
+                       $logger,
+                       [ function () use ( $services ) {
+                               $services->getResourceLoader()->getMessageBlobStore()->clear();
+                       } ]
+               );
+       },
+
        'LocalServerObjectCache' => function ( MediaWikiServices $services ) : BagOStuff {
                $config = $services->getMainConfig();
                $cacheId = ObjectCache::detectLocalServerCache();
@@ -522,7 +552,7 @@ return [
        },
 
        'PasswordReset' => function ( MediaWikiServices $services ) : PasswordReset {
-               $options = new ServiceOptions( PasswordReset::$constructorOptions, $services->getMainConfig() );
+               $options = new ServiceOptions( PasswordReset::CONSTRUCTOR_OPTIONS, $services->getMainConfig() );
                return new PasswordReset(
                        $options,
                        AuthManager::singleton(),
@@ -556,7 +586,7 @@ return [
        'PreferencesFactory' => function ( MediaWikiServices $services ) : PreferencesFactory {
                $factory = new DefaultPreferencesFactory(
                        new ServiceOptions(
-                               DefaultPreferencesFactory::$constructorOptions, $services->getMainConfig() ),
+                               DefaultPreferencesFactory::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ),
                        $services->getContentLanguage(),
                        AuthManager::singleton(),
                        $services->getLinkRendererFactory()->create(),
index c6d6b8f..a9e6969 100644 (file)
 
 use CLDRPluralRuleParser\Evaluator;
 use CLDRPluralRuleParser\Error as CLDRPluralRuleError;
-use MediaWiki\Logger\LoggerFactory;
-use MediaWiki\MediaWikiServices;
+use MediaWiki\Config\ServiceOptions;
+use Psr\Log\LoggerInterface;
 
 /**
  * Class for caching the contents of localisation files, Messages*.php
  * and *.i18n.php.
  *
- * An instance of this class is available using Language::getLocalisationCache().
+ * An instance of this class is available using MediaWikiServices.
  *
  * The values retrieved from here are merged, containing items from extension
  * files, core messages files and the language fallback sequence (e.g. zh-cn ->
@@ -40,8 +40,8 @@ use MediaWiki\MediaWikiServices;
 class LocalisationCache {
        const VERSION = 4;
 
-       /** Configuration associative array */
-       private $conf;
+       /** @var ServiceOptions */
+       private $options;
 
        /**
         * True if recaching should only be done on an explicit call to recache().
@@ -50,11 +50,6 @@ class LocalisationCache {
         */
        private $manualRecache = false;
 
-       /**
-        * True to treat all files as expired until they are regenerated by this object.
-        */
-       private $forceRecache = false;
-
        /**
         * The cache data. 3-d array, where the first key is the language code,
         * the second key is the item key e.g. 'messages', and the third key is
@@ -71,16 +66,20 @@ class LocalisationCache {
        private $store;
 
        /**
-        * @var \Psr\Log\LoggerInterface
+        * @var LoggerInterface
         */
        private $logger;
 
+       /** @var callable[] See comment for parameter in constructor */
+       private $clearStoreCallbacks;
+
        /**
         * A 2-d associative array, code/key, where presence indicates that the item
         * is loaded. Value arbitrary.
         *
         * For split items, if set, this indicates that all of the subitems have been
         * loaded.
+        *
         */
        private $loadedItems = [];
 
@@ -189,59 +188,79 @@ class LocalisationCache {
        private $mergeableKeys = null;
 
        /**
-        * For constructor parameters, see the documentation in DefaultSettings.php
-        * for $wgLocalisationCacheConf.
+        * Return a suitable LCStore as specified by the given configuration.
         *
-        * @param array $conf
-        * @throws MWException
+        * @since 1.34
+        * @param array $conf In the format of $wgLocalisationCacheConf
+        * @param string|false|null $fallbackCacheDir In case 'storeDirectory' isn't specified
+        * @return LCStore
         */
-       function __construct( $conf ) {
-               global $wgCacheDirectory;
-
-               $this->conf = $conf;
-               $this->logger = LoggerFactory::getInstance( 'localisation' );
-
-               $directory = !empty( $conf['storeDirectory'] ) ? $conf['storeDirectory'] : $wgCacheDirectory;
+       public static function getStoreFromConf( array $conf, $fallbackCacheDir ) : LCStore {
                $storeArg = [];
-               $storeArg['directory'] = $directory;
+               $storeArg['directory'] =
+                       $conf['storeDirectory'] ?: $fallbackCacheDir;
 
                if ( !empty( $conf['storeClass'] ) ) {
                        $storeClass = $conf['storeClass'];
+               } elseif ( $conf['store'] === 'files' || $conf['store'] === 'file' ||
+                       ( $conf['store'] === 'detect' && $storeArg['directory'] )
+               ) {
+                       $storeClass = LCStoreCDB::class;
+               } elseif ( $conf['store'] === 'db' || $conf['store'] === 'detect' ) {
+                       $storeClass = LCStoreDB::class;
+                       $storeArg['server'] = $conf['storeServer'] ?? [];
+               } elseif ( $conf['store'] === 'array' ) {
+                       $storeClass = LCStoreStaticArray::class;
                } else {
-                       switch ( $conf['store'] ) {
-                               case 'files':
-                               case 'file':
-                                       $storeClass = LCStoreCDB::class;
-                                       break;
-                               case 'db':
-                                       $storeClass = LCStoreDB::class;
-                                       $storeArg['server'] = $conf['storeServer'] ?? [];
-                                       break;
-                               case 'array':
-                                       $storeClass = LCStoreStaticArray::class;
-                                       break;
-                               case 'detect':
-                                       if ( $directory ) {
-                                               $storeClass = LCStoreCDB::class;
-                                       } else {
-                                               $storeClass = LCStoreDB::class;
-                                               $storeArg['server'] = $conf['storeServer'] ?? [];
-                                       }
-                                       break;
-                               default:
-                                       throw new MWException(
-                                               'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.'
-                                       );
-                       }
+                       throw new MWException(
+                               'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.'
+                       );
                }
-               $this->logger->debug( static::class . ": using store $storeClass" );
 
-               $this->store = new $storeClass( $storeArg );
-               foreach ( [ 'manualRecache', 'forceRecache' ] as $var ) {
-                       if ( isset( $conf[$var] ) ) {
-                               $this->$var = $conf[$var];
-                       }
-               }
+               return new $storeClass( $storeArg );
+       }
+
+       /**
+        * @var array
+        * @since 1.34
+        */
+       public const CONSTRUCTOR_OPTIONS = [
+               // True to treat all files as expired until they are regenerated by this object.
+               'forceRecache',
+               'manualRecache',
+               'ExtensionMessagesFiles',
+               'MessagesDirs',
+       ];
+
+       /**
+        * For constructor parameters, see the documentation in DefaultSettings.php
+        * for $wgLocalisationCacheConf.
+        *
+        * Do not construct this directly. Use MediaWikiServices.
+        *
+        * @param ServiceOptions $options
+        * @param LCStore $store What backend to use for storage
+        * @param LoggerInterface $logger
+        * @param callable[] $clearStoreCallbacks To be called whenever the cache is cleared. Can be
+        *   used to clear other caches that depend on this one, such as ResourceLoader's
+        *   MessageBlobStore.
+        * @throws MWException
+        */
+       function __construct(
+               ServiceOptions $options,
+               LCStore $store,
+               LoggerInterface $logger,
+               array $clearStoreCallbacks = []
+       ) {
+               $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
+
+               $this->options = $options;
+               $this->store = $store;
+               $this->logger = $logger;
+               $this->clearStoreCallbacks = $clearStoreCallbacks;
+
+               // Keep this separate from $this->options so it can be mutable
+               $this->manualRecache = $options->get( 'manualRecache' );
        }
 
        /**
@@ -406,7 +425,7 @@ class LocalisationCache {
         * @return bool
         */
        public function isExpired( $code ) {
-               if ( $this->forceRecache && !isset( $this->recachedLangs[$code] ) ) {
+               if ( $this->options->get( 'forceRecache' ) && !isset( $this->recachedLangs[$code] ) ) {
                        $this->logger->debug( __METHOD__ . "($code): forced reload" );
 
                        return true;
@@ -796,14 +815,12 @@ class LocalisationCache {
        public function getMessagesDirs() {
                global $IP;
 
-               $config = MediaWikiServices::getInstance()->getMainConfig();
-               $messagesDirs = $config->get( 'MessagesDirs' );
                return [
                        'core' => "$IP/languages/i18n",
                        'exif' => "$IP/languages/i18n/exif",
                        'api' => "$IP/includes/api/i18n",
                        'oojs-ui' => "$IP/resources/lib/ooui/i18n",
-               ] + $messagesDirs;
+               ] + $this->options->get( 'MessagesDirs' );
        }
 
        /**
@@ -813,8 +830,6 @@ class LocalisationCache {
         * @throws MWException
         */
        public function recache( $code ) {
-               global $wgExtensionMessagesFiles;
-
                if ( !$code ) {
                        throw new MWException( "Invalid language code requested" );
                }
@@ -861,7 +876,7 @@ class LocalisationCache {
 
                # Load non-JSON localisation data for extensions
                $extensionData = array_fill_keys( $codeSequence, $initialData );
-               foreach ( $wgExtensionMessagesFiles as $extension => $fileName ) {
+               foreach ( $this->options->get( 'ExtensionMessagesFiles' ) as $extension => $fileName ) {
                        if ( isset( $messageDirs[$extension] ) ) {
                                # This extension has JSON message data; skip the PHP shim
                                continue;
@@ -1023,8 +1038,9 @@ class LocalisationCache {
                # HACK: If using a null (i.e. disabled) storage backend, we
                # can't write to the MessageBlobStore either
                if ( !$this->store instanceof LCStoreNull ) {
-                       $blobStore = MediaWikiServices::getInstance()->getResourceLoader()->getMessageBlobStore();
-                       $blobStore->clear();
+                       foreach ( $this->clearStoreCallbacks as $callback ) {
+                               $callback();
+                       }
                }
        }
 
@@ -1085,5 +1101,4 @@ class LocalisationCache {
                $this->store = new LCStoreNull;
                $this->manualRecache = false;
        }
-
 }
index b830b70..091f93b 100644 (file)
@@ -412,14 +412,17 @@ abstract class Installer {
                // This will be overridden in the web installer with the user-specified language
                RequestContext::getMain()->setLanguage( 'en' );
 
-               // Disable the i18n cache
-               // TODO: manage LocalisationCache singleton in MediaWikiServices
-               Language::getLocalisationCache()->disableBackend();
-
                // Disable all global services, since we don't have any configuration yet!
                MediaWikiServices::disableStorageBackend();
 
                $mwServices = MediaWikiServices::getInstance();
+
+               // Disable i18n cache
+               $mwServices->getLocalisationCache()->disableBackend();
+
+               // Clear language cache so the old i18n cache doesn't sneak back in
+               Language::clearCaches();
+
                // Disable object cache (otherwise CACHE_ANYTHING will try CACHE_DB and
                // SqlBagOStuff will then throw since we just disabled wfGetDB)
                $wgObjectCaches = $mwServices->getMainConfig()->get( 'ObjectCaches' );
index 68236e5..6cee732 100644 (file)
@@ -81,12 +81,10 @@ class DefaultPreferencesFactory implements PreferencesFactory {
        protected $permissionManager;
 
        /**
-        * TODO Make this a const when we drop HHVM support (T192166)
-        *
         * @var array
         * @since 1.34
         */
-       public static $constructorOptions = [
+       public const CONSTRUCTOR_OPTIONS = [
                'AllowRequiringEmailForResets',
                'AllowUserCss',
                'AllowUserCssPrefs',
@@ -132,7 +130,7 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                NamespaceInfo $nsInfo,
                PermissionManager $permissionManager
        ) {
-               $options->assertRequiredOptions( self::$constructorOptions );
+               $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
 
                $this->options = $options;
                $this->contLang = $contLang;
index 8ef1d0d..2958b1f 100644 (file)
@@ -60,7 +60,8 @@ class PasswordReset implements LoggerAwareInterface {
         */
        private $permissionCache;
 
-       public static $constructorOptions = [
+       public const CONSTRUCTOR_OPTIONS = [
+               'AllowRequiringEmailForResets',
                'EnableEmail',
                'PasswordResetRoutes',
        ];
@@ -166,12 +167,14 @@ class PasswordReset implements LoggerAwareInterface {
                                . ' is not allowed to reset passwords' );
                }
 
+               $username = $username ?? '';
+               $email = $email ?? '';
+
                $resetRoutes = $this->config->get( 'PasswordResetRoutes' )
                        + [ 'username' => false, 'email' => false ];
                if ( $resetRoutes['username'] && $username ) {
                        $method = 'username';
-                       $users = [ User::newFromName( $username ) ];
-                       $email = null;
+                       $users = [ $this->lookupUser( $username ) ];
                } elseif ( $resetRoutes['email'] && $email ) {
                        if ( !Sanitizer::validateEmail( $email ) ) {
                                return StatusValue::newFatal( 'passwordreset-invalidemail' );
@@ -188,12 +191,33 @@ class PasswordReset implements LoggerAwareInterface {
                $error = [];
                $data = [
                        'Username' => $username,
-                       'Email' => $email,
+                       // Email gets set to null for backward compatibility
+                       'Email' => $method === 'email' ? $email : null,
                ];
                if ( !Hooks::run( 'SpecialPasswordResetOnSubmit', [ &$users, $data, &$error ] ) ) {
                        return StatusValue::newFatal( Message::newFromSpecifier( $error ) );
                }
 
+               $firstUser = $users[0] ?? null;
+               $requireEmail = $this->config->get( 'AllowRequiringEmailForResets' )
+                       && $method === 'username'
+                       && $firstUser
+                       && $firstUser->getBoolOption( 'requireemail' );
+               if ( $requireEmail ) {
+                       if ( $email === '' ) {
+                               return StatusValue::newFatal( 'passwordreset-username-email-required' );
+                       }
+
+                       if ( !Sanitizer::validateEmail( $email ) ) {
+                               return StatusValue::newFatal( 'passwordreset-invalidemail' );
+                       }
+               }
+
+               // Check against the rate limiter
+               if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
+                       return StatusValue::newFatal( 'actionthrottledtext' );
+               }
+
                if ( !$users ) {
                        if ( $method === 'email' ) {
                                // Don't reveal whether or not an email address is in use
@@ -203,18 +227,11 @@ class PasswordReset implements LoggerAwareInterface {
                        }
                }
 
-               $firstUser = $users[0];
-
                if ( !$firstUser instanceof User || !$firstUser->getId() ) {
                        // Don't parse username as wikitext (T67501)
                        return StatusValue::newFatal( wfMessage( 'nosuchuser', wfEscapeWikiText( $username ) ) );
                }
 
-               // Check against the rate limiter
-               if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
-                       return StatusValue::newFatal( 'actionthrottledtext' );
-               }
-
                // All the users will have the same email address
                if ( !$firstUser->getEmail() ) {
                        // This won't be reachable from the email route, so safe to expose the username
@@ -222,6 +239,11 @@ class PasswordReset implements LoggerAwareInterface {
                                wfEscapeWikiText( $firstUser->getName() ) ) );
                }
 
+               if ( $requireEmail && $firstUser->getEmail() !== $email ) {
+                       // Pretend everything's fine to avoid disclosure
+                       return StatusValue::newGood();
+               }
+
                // We need to have a valid IP address for the hook, but per T20347, we should
                // send the user's name if they're logged in.
                $ip = $performingUser->getRequest()->getIP();
@@ -324,4 +346,15 @@ class PasswordReset implements LoggerAwareInterface {
                }
                return $users;
        }
+
+       /**
+        * User object creation helper for testability
+        * @codeCoverageIgnore
+        *
+        * @param string $username
+        * @return User|false
+        */
+       protected function lookupUser( $username ) {
+               return User::newFromName( $username );
+       }
 }
index 51ff8d5..a8950f5 100644 (file)
@@ -77,10 +77,8 @@ class Language {
         */
        public $transformData = [];
 
-       /**
-        * @var LocalisationCache
-        */
-       public static $dataCache;
+       /** @var LocalisationCache */
+       private $localisationCache;
 
        public static $mLangObjCache = [];
 
@@ -285,12 +283,12 @@ class Language {
         * @since 1.32
         */
        public static function clearCaches() {
-               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
-                       throw new MWException( __METHOD__ . ' must not be used outside tests' );
+               if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MEDIAWIKI_INSTALL' ) ) {
+                       throw new MWException( __METHOD__ . ' must not be used outside tests/installer' );
+               }
+               if ( defined( 'MW_PHPUNIT_TEST' ) ) {
+                       MediaWikiServices::getInstance()->resetServiceForTesting( 'LocalisationCache' );
                }
-               self::$dataCache = null;
-               // Reinitialize $dataCache, since it's expected to always be available
-               self::getLocalisationCache();
                self::$mLangObjCache = [];
                self::$fallbackLanguageCache = [];
                self::$grammarTransformations = null;
@@ -445,15 +443,11 @@ class Language {
        /**
         * Get the LocalisationCache instance
         *
+        * @deprecated since 1.34, use MediaWikiServices
         * @return LocalisationCache
         */
        public static function getLocalisationCache() {
-               if ( is_null( self::$dataCache ) ) {
-                       global $wgLocalisationCacheConf;
-                       $class = $wgLocalisationCacheConf['class'];
-                       self::$dataCache = new $class( $wgLocalisationCacheConf );
-               }
-               return self::$dataCache;
+               return MediaWikiServices::getInstance()->getLocalisationCache();
        }
 
        function __construct() {
@@ -464,7 +458,7 @@ class Language {
                } else {
                        $this->mCode = str_replace( '_', '-', strtolower( substr( static::class, 8 ) ) );
                }
-               self::getLocalisationCache();
+               $this->localisationCache = MediaWikiServices::getInstance()->getLocalisationCache();
        }
 
        /**
@@ -497,7 +491,7 @@ class Language {
         * @return array
         */
        public function getBookstoreList() {
-               return self::$dataCache->getItem( $this->mCode, 'bookstoreList' );
+               return $this->localisationCache->getItem( $this->mCode, 'bookstoreList' );
        }
 
        /**
@@ -514,7 +508,7 @@ class Language {
                                getCanonicalNamespaces();
 
                        $this->namespaceNames = $wgExtraNamespaces +
-                               self::$dataCache->getItem( $this->mCode, 'namespaceNames' );
+                               $this->localisationCache->getItem( $this->mCode, 'namespaceNames' );
                        $this->namespaceNames += $validNamespaces;
 
                        $this->namespaceNames[NS_PROJECT] = $wgMetaNamespace;
@@ -621,7 +615,7 @@ class Language {
                global $wgExtraGenderNamespaces;
 
                $ns = $wgExtraGenderNamespaces +
-                       (array)self::$dataCache->getItem( $this->mCode, 'namespaceGenderAliases' );
+                       (array)$this->localisationCache->getItem( $this->mCode, 'namespaceGenderAliases' );
 
                return $ns[$index][$gender] ?? $this->getNsText( $index );
        }
@@ -643,7 +637,7 @@ class Language {
                        return false;
                } else {
                        // Check what is in i18n files
-                       $aliases = self::$dataCache->getItem( $this->mCode, 'namespaceGenderAliases' );
+                       $aliases = $this->localisationCache->getItem( $this->mCode, 'namespaceGenderAliases' );
                        return count( $aliases ) > 0;
                }
        }
@@ -667,7 +661,7 @@ class Language {
         */
        public function getNamespaceAliases() {
                if ( is_null( $this->namespaceAliases ) ) {
-                       $aliases = self::$dataCache->getItem( $this->mCode, 'namespaceAliases' );
+                       $aliases = $this->localisationCache->getItem( $this->mCode, 'namespaceAliases' );
                        if ( !$aliases ) {
                                $aliases = [];
                        } else {
@@ -681,8 +675,8 @@ class Language {
                        }
 
                        global $wgExtraGenderNamespaces;
-                       $genders = $wgExtraGenderNamespaces +
-                               (array)self::$dataCache->getItem( $this->mCode, 'namespaceGenderAliases' );
+                       $genders = $wgExtraGenderNamespaces + (array)$this->localisationCache
+                               ->getItem( $this->mCode, 'namespaceGenderAliases' );
                        foreach ( $genders as $index => $forms ) {
                                foreach ( $forms as $alias ) {
                                        $aliases[$alias] = $index;
@@ -783,21 +777,21 @@ class Language {
         * @return string[]|bool List of date format preference keys, or false if disabled.
         */
        public function getDatePreferences() {
-               return self::$dataCache->getItem( $this->mCode, 'datePreferences' );
+               return $this->localisationCache->getItem( $this->mCode, 'datePreferences' );
        }
 
        /**
         * @return array
         */
        function getDateFormats() {
-               return self::$dataCache->getItem( $this->mCode, 'dateFormats' );
+               return $this->localisationCache->getItem( $this->mCode, 'dateFormats' );
        }
 
        /**
         * @return array|string
         */
        public function getDefaultDateFormat() {
-               $df = self::$dataCache->getItem( $this->mCode, 'defaultDateFormat' );
+               $df = $this->localisationCache->getItem( $this->mCode, 'defaultDateFormat' );
                if ( $df === 'dmy or mdy' ) {
                        global $wgAmericanDates;
                        return $wgAmericanDates ? 'mdy' : 'dmy';
@@ -810,7 +804,7 @@ class Language {
         * @return array
         */
        public function getDatePreferenceMigrationMap() {
-               return self::$dataCache->getItem( $this->mCode, 'datePreferenceMigrationMap' );
+               return $this->localisationCache->getItem( $this->mCode, 'datePreferenceMigrationMap' );
        }
 
        /**
@@ -2277,7 +2271,8 @@ class Language {
                }
 
                if ( !isset( $this->dateFormatStrings[$type][$pref] ) ) {
-                       $df = self::$dataCache->getSubitem( $this->mCode, 'dateFormats', "$pref $type" );
+                       $df =
+                               $this->localisationCache->getSubitem( $this->mCode, 'dateFormats', "$pref $type" );
 
                        if ( $type === 'pretty' && $df === null ) {
                                $df = $this->getDateFormatString( 'date', $pref );
@@ -2285,7 +2280,8 @@ class Language {
 
                        if ( !$wasDefault && $df === null ) {
                                $pref = $this->getDefaultDateFormat();
-                               $df = self::$dataCache->getSubitem( $this->mCode, 'dateFormats', "$pref $type" );
+                               $df = $this->getLocalisationCache()
+                                       ->getSubitem( $this->mCode, 'dateFormats', "$pref $type" );
                        }
 
                        $this->dateFormatStrings[$type][$pref] = $df;
@@ -2649,14 +2645,14 @@ class Language {
         * @return string|null
         */
        public function getMessage( $key ) {
-               return self::$dataCache->getSubitem( $this->mCode, 'messages', $key );
+               return $this->localisationCache->getSubitem( $this->mCode, 'messages', $key );
        }
 
        /**
         * @return array
         */
        function getAllMessages() {
-               return self::$dataCache->getItem( $this->mCode, 'messages' );
+               return $this->localisationCache->getItem( $this->mCode, 'messages' );
        }
 
        /**
@@ -2898,7 +2894,7 @@ class Language {
         * @return string
         */
        function fallback8bitEncoding() {
-               return self::$dataCache->getItem( $this->mCode, 'fallback8bitEncoding' );
+               return $this->localisationCache->getItem( $this->mCode, 'fallback8bitEncoding' );
        }
 
        /**
@@ -3088,7 +3084,7 @@ class Language {
         * @return bool
         */
        function isRTL() {
-               return self::$dataCache->getItem( $this->mCode, 'rtl' );
+               return $this->localisationCache->getItem( $this->mCode, 'rtl' );
        }
 
        /**
@@ -3164,7 +3160,7 @@ class Language {
         * @return array
         */
        function capitalizeAllNouns() {
-               return self::$dataCache->getItem( $this->mCode, 'capitalizeAllNouns' );
+               return $this->localisationCache->getItem( $this->mCode, 'capitalizeAllNouns' );
        }
 
        /**
@@ -3197,7 +3193,7 @@ class Language {
         * @return bool
         */
        function linkPrefixExtension() {
-               return self::$dataCache->getItem( $this->mCode, 'linkPrefixExtension' );
+               return $this->localisationCache->getItem( $this->mCode, 'linkPrefixExtension' );
        }
 
        /**
@@ -3205,7 +3201,7 @@ class Language {
         * @return array
         */
        function getMagicWords() {
-               return self::$dataCache->getItem( $this->mCode, 'magicWords' );
+               return $this->localisationCache->getItem( $this->mCode, 'magicWords' );
        }
 
        /**
@@ -3215,7 +3211,7 @@ class Language {
         */
        function getMagic( $mw ) {
                $rawEntry = $this->mMagicExtensions[$mw->mId] ??
-                       self::$dataCache->getSubitem( $this->mCode, 'magicWords', $mw->mId );
+                       $this->localisationCache->getSubitem( $this->mCode, 'magicWords', $mw->mId );
 
                if ( !is_array( $rawEntry ) ) {
                        wfWarn( "\"$rawEntry\" is not a valid magic word for \"$mw->mId\"" );
@@ -3250,7 +3246,7 @@ class Language {
                if ( is_null( $this->mExtendedSpecialPageAliases ) ) {
                        // Initialise array
                        $this->mExtendedSpecialPageAliases =
-                               self::$dataCache->getItem( $this->mCode, 'specialPageAliases' );
+                               $this->localisationCache->getItem( $this->mCode, 'specialPageAliases' );
                }
 
                return $this->mExtendedSpecialPageAliases;
@@ -3415,28 +3411,28 @@ class Language {
         * @return string
         */
        function digitGroupingPattern() {
-               return self::$dataCache->getItem( $this->mCode, 'digitGroupingPattern' );
+               return $this->localisationCache->getItem( $this->mCode, 'digitGroupingPattern' );
        }
 
        /**
         * @return array
         */
        function digitTransformTable() {
-               return self::$dataCache->getItem( $this->mCode, 'digitTransformTable' );
+               return $this->localisationCache->getItem( $this->mCode, 'digitTransformTable' );
        }
 
        /**
         * @return array
         */
        function separatorTransformTable() {
-               return self::$dataCache->getItem( $this->mCode, 'separatorTransformTable' );
+               return $this->localisationCache->getItem( $this->mCode, 'separatorTransformTable' );
        }
 
        /**
         * @return int|null
         */
        function minimumGroupingDigits() {
-               return self::$dataCache->getItem( $this->mCode, 'minimumGroupingDigits' );
+               return $this->localisationCache->getItem( $this->mCode, 'minimumGroupingDigits' );
        }
 
        /**
@@ -4336,7 +4332,7 @@ class Language {
         * @return string
         */
        public function linkTrail() {
-               return self::$dataCache->getItem( $this->mCode, 'linkTrail' );
+               return $this->localisationCache->getItem( $this->mCode, 'linkTrail' );
        }
 
        /**
@@ -4346,7 +4342,7 @@ class Language {
         * @return string
         */
        public function linkPrefixCharset() {
-               return self::$dataCache->getItem( $this->mCode, 'linkPrefixCharset' );
+               return $this->localisationCache->getItem( $this->mCode, 'linkPrefixCharset' );
        }
 
        /**
@@ -4936,11 +4932,13 @@ class Language {
         * @return array Associative array with plural form, and plural rule as key-value pairs
         */
        public function getCompiledPluralRules() {
-               $pluralRules = self::$dataCache->getItem( strtolower( $this->mCode ), 'compiledPluralRules' );
+               $pluralRules =
+                       $this->localisationCache->getItem( strtolower( $this->mCode ), 'compiledPluralRules' );
                $fallbacks = self::getFallbacksFor( $this->mCode );
                if ( !$pluralRules ) {
                        foreach ( $fallbacks as $fallbackCode ) {
-                               $pluralRules = self::$dataCache->getItem( strtolower( $fallbackCode ), 'compiledPluralRules' );
+                               $pluralRules = $this->localisationCache
+                                       ->getItem( strtolower( $fallbackCode ), 'compiledPluralRules' );
                                if ( $pluralRules ) {
                                        break;
                                }
@@ -4955,11 +4953,13 @@ class Language {
         * @return array Associative array with plural form number and plural rule as key-value pairs
         */
        public function getPluralRules() {
-               $pluralRules = self::$dataCache->getItem( strtolower( $this->mCode ), 'pluralRules' );
+               $pluralRules =
+                       $this->localisationCache->getItem( strtolower( $this->mCode ), 'pluralRules' );
                $fallbacks = self::getFallbacksFor( $this->mCode );
                if ( !$pluralRules ) {
                        foreach ( $fallbacks as $fallbackCode ) {
-                               $pluralRules = self::$dataCache->getItem( strtolower( $fallbackCode ), 'pluralRules' );
+                               $pluralRules = $this->localisationCache
+                                       ->getItem( strtolower( $fallbackCode ), 'pluralRules' );
                                if ( $pluralRules ) {
                                        break;
                                }
@@ -4974,11 +4974,13 @@ class Language {
         * @return array Associative array with plural form number and plural rule type as key-value pairs
         */
        public function getPluralRuleTypes() {
-               $pluralRuleTypes = self::$dataCache->getItem( strtolower( $this->mCode ), 'pluralRuleTypes' );
+               $pluralRuleTypes =
+                       $this->localisationCache->getItem( strtolower( $this->mCode ), 'pluralRuleTypes' );
                $fallbacks = self::getFallbacksFor( $this->mCode );
                if ( !$pluralRuleTypes ) {
                        foreach ( $fallbacks as $fallbackCode ) {
-                               $pluralRuleTypes = self::$dataCache->getItem( strtolower( $fallbackCode ), 'pluralRuleTypes' );
+                               $pluralRuleTypes = $this->localisationCache
+                                       ->getItem( strtolower( $fallbackCode ), 'pluralRuleTypes' );
                                if ( $pluralRuleTypes ) {
                                        break;
                                }
index 54f5567..92d1d9c 100644 (file)
        "passwordreset-ignored": "The password reset was not handled. Maybe no provider was configured?",
        "passwordreset-invalidemail": "Invalid email address",
        "passwordreset-nodata": "Neither a username nor an email address was supplied",
+       "passwordreset-username-email-required": "Both username and email address are required to receive a temporary password via email.",
        "changeemail": "Change or remove email address",
        "changeemail-summary": "",
        "changeemail-header": "Complete this form to change your email address. If you would like to remove the association of any email address from your account, leave the new email address blank when submitting the form.",
index 4fa60a2..dc8c74b 100644 (file)
        "passwordreset-ignored": "Shown when password reset was unsuccessful due to configuration problems.",
        "passwordreset-invalidemail": "Returned when the email address is syntatically invalid.",
        "passwordreset-nodata": "Returned when no data was provided.",
+       "passwordreset-username-email-required": "Used in [[Special:PasswordReset]].\n\nSee also:\n* {{msg-mw|tog-requireemail}}\n* {{msg-mw|prefs-help-requireemail}}",
        "changeemail": "Title of [[Special:ChangeEmail|special page]]. This page also allows removing the user's email address.",
        "changeemail-summary": "{{ignored}}",
        "changeemail-header": "Text of [[Special:ChangeEmail]].",
index 4213d5f..07c5569 100644 (file)
  * @ingroup Maintenance
  */
 
+use MediaWiki\Config\ServiceOptions;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+
 require_once __DIR__ . '/Maintenance.php';
 
 /**
@@ -58,7 +62,7 @@ class RebuildLocalisationCache extends Maintenance {
        }
 
        public function execute() {
-               global $wgLocalisationCacheConf;
+               global $wgLocalisationCacheConf, $wgCacheDirectory;
 
                $force = $this->hasOption( 'force' );
                $threads = $this->getOption( 'threads', 1 );
@@ -77,13 +81,24 @@ class RebuildLocalisationCache extends Maintenance {
 
                $conf = $wgLocalisationCacheConf;
                $conf['manualRecache'] = false; // Allow fallbacks to create CDB files
-               if ( $force ) {
-                       $conf['forceRecache'] = true;
-               }
+               $conf['forceRecache'] = $force || !empty( $conf['forceRecache'] );
                if ( $this->hasOption( 'outdir' ) ) {
                        $conf['storeDirectory'] = $this->getOption( 'outdir' );
                }
-               $lc = new LocalisationCacheBulkLoad( $conf );
+               // XXX Copy-pasted from ServiceWiring.php. Do we need a factory for this one caller?
+               $lc = new LocalisationCacheBulkLoad(
+                       new ServiceOptions(
+                               LocalisationCache::CONSTRUCTOR_OPTIONS,
+                               $conf,
+                               MediaWikiServices::getInstance()->getMainConfig()
+                       ),
+                       LocalisationCache::getStoreFromConf( $conf, $wgCacheDirectory ),
+                       LoggerFactory::getInstance( 'localisation' ),
+                       [ function () {
+                               MediaWikiServices::getInstance()->getResourceLoader()
+                                       ->getMessageBlobStore()->clear();
+                       } ]
+               );
 
                $allCodes = array_keys( Language::fetchLanguageNames( null, 'mwfile' ) );
                if ( $this->hasOption( 'lang' ) ) {
index 141e307..85c5881 100644 (file)
@@ -70,6 +70,7 @@ class TestSetup {
                // Assume UTC for testing purposes
                $wgLocaltimezone = 'UTC';
 
+               $wgLocalisationCacheConf['class'] = TestLocalisationCache::class;
                $wgLocalisationCacheConf['storeClass'] = LCStoreNull::class;
 
                // Do not bother updating search tables
index c479c2d..1657e81 100644 (file)
@@ -71,6 +71,7 @@ $wgAutoloadClasses += [
        'ResourceLoaderFileTestModule' => "$testDir/phpunit/ResourceLoaderTestCase.php",
        'ResourceLoaderTestCase' => "$testDir/phpunit/ResourceLoaderTestCase.php",
        'ResourceLoaderTestModule' => "$testDir/phpunit/ResourceLoaderTestCase.php",
+       'TestLocalisationCache' => "$testDir/phpunit/includes/TestLocalisationCache.php",
        'TestUser' => "$testDir/phpunit/includes/TestUser.php",
        'TestUserRegistry' => "$testDir/phpunit/includes/TestUserRegistry.php",
 
index a82c064..24a601e 100644 (file)
@@ -407,6 +407,7 @@ abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase {
 
                $wgRequest = new FauxRequest();
                MediaWiki\Session\SessionManager::resetCache();
+               Language::clearCaches();
        }
 
        public function run( PHPUnit_Framework_TestResult $result = null ) {
diff --git a/tests/phpunit/includes/TestLocalisationCache.php b/tests/phpunit/includes/TestLocalisationCache.php
new file mode 100644 (file)
index 0000000..03b98a2
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * A test-only LocalisationCache that caches all data in memory for test speed.
+ */
+class TestLocalisationCache extends LocalisationCache {
+
+       /**
+        * A cache of the parsed data for tests. Services are reset between every test, which forces
+        * localization to be recached between every test, which is unreasonably slow. As an
+        * optimization, we cache our data in a static member for tests.
+        *
+        * @var array
+        */
+       private static $testingCache = [];
+
+       private $selfAccess;
+
+       public function __construct() {
+               parent::__construct( ...func_get_args() );
+               $this->selfAccess = TestingAccessWrapper::newFromObject( $this );
+       }
+
+       /**
+        * Recurse through the given array and replace every object by a scalar value that can be
+        * serialized as JSON to use as a hash key.
+        *
+        * @param array $arr
+        * @return array
+        */
+       private static function hashiblifyArray( array $arr ) : array {
+               foreach ( $arr as $key => $val ) {
+                       if ( is_array( $val ) ) {
+                               $arr[$key] = self::hashiblifyArray( $val );
+                       } elseif ( is_object( $val ) ) {
+                               // spl_object_hash() may return duplicate values if an object is destroyed and a new
+                               // one gets its hash and happens to be registered in the same hook in the same
+                               // location. This seems unlikely, but let's be safe and maintain a reference so it
+                               // can't happen. (In practice, there are probably no objects in the hooks at all.)
+                               static $objects = [];
+                               if ( !in_array( $val, $objects, true ) ) {
+                                       $objects[] = $val;
+                               }
+                               $arr[$key] = spl_object_hash( $val );
+                       }
+               }
+               return $arr;
+       }
+
+       public function recache( $code ) {
+               // Test run performance is killed if we have to regenerate l10n for every test
+               $cacheKey = sha1( json_encode( [
+                       $code,
+                       $this->selfAccess->options->get( 'ExtensionMessagesFiles' ),
+                       $this->selfAccess->options->get( 'MessagesDirs' ),
+                       // json_encode doesn't handle objects well
+                       self::hashiblifyArray( Hooks::getHandlers( 'LocalisationCacheRecacheFallback' ) ),
+                       self::hashiblifyArray( Hooks::getHandlers( 'LocalisationCacheRecache' ) ),
+               ] ) );
+               if ( isset( self::$testingCache[$cacheKey] ) ) {
+                       $this->data[$code] = self::$testingCache[$cacheKey];
+                       foreach ( self::$testingCache[$cacheKey] as $key => $item ) {
+                               $loadedItems = $this->selfAccess->loadedItems;
+                               $loadedItems[$code][$key] = true;
+                               $this->selfAccess->loadedItems = $loadedItems;
+                       }
+                       return;
+               }
+
+               parent::recache( $code );
+
+               if ( count( self::$testingCache ) > 4 ) {
+                       // Don't store more than a few $data's, they can add up to a lot of memory if
+                       // they're kept around for the whole test duration
+                       array_pop( self::$testingCache );
+               }
+               // Put the new one in front
+               self::$testingCache = array_merge( [ $cacheKey => $this->data[$code] ], self::$testingCache );
+       }
+}
index 42957b6..af1ff86 100644 (file)
@@ -1,4 +1,8 @@
 <?php
+
+use MediaWiki\Config\ServiceOptions;
+use Psr\Log\NullLogger;
+
 /**
  * @group Database
  * @group Cache
@@ -19,8 +23,18 @@ class LocalisationCacheTest extends MediaWikiTestCase {
         */
        protected function getMockLocalisationCache() {
                global $IP;
-               $lc = $this->getMockBuilder( \LocalisationCache::class )
-                       ->setConstructorArgs( [ [ 'store' => 'detect' ] ] )
+
+               $lc = $this->getMockBuilder( LocalisationCache::class )
+                       ->setConstructorArgs( [
+                               new ServiceOptions( LocalisationCache::CONSTRUCTOR_OPTIONS, [
+                                       'forceRecache' => false,
+                                       'manualRecache' => false,
+                                       'ExtensionMessagesFiles' => [],
+                                       'MessagesDirs' => [],
+                               ] ),
+                               new LCStoreDB( [] ),
+                               new NullLogger
+                       ] )
                        ->setMethods( [ 'getMessagesDirs' ] )
                        ->getMock();
                $lc->expects( $this->any() )->method( 'getMessagesDirs' )
@@ -31,7 +45,7 @@ class LocalisationCacheTest extends MediaWikiTestCase {
                return $lc;
        }
 
-       public function testPuralRulesFallback() {
+       public function testPluralRulesFallback() {
                $cache = $this->getMockLocalisationCache();
 
                $this->assertEquals(
index 4bb9d5a..839272f 100644 (file)
@@ -39,13 +39,13 @@ class LogFormatterTest extends MediaWikiLangTestCase {
                global $wgExtensionMessagesFiles;
                self::$oldExtMsgFiles = $wgExtensionMessagesFiles;
                $wgExtensionMessagesFiles['LogTests'] = __DIR__ . '/LogTests.i18n.php';
-               Language::getLocalisationCache()->recache( 'en' );
+               Language::clearCaches();
        }
 
        public static function tearDownAfterClass() {
                global $wgExtensionMessagesFiles;
                $wgExtensionMessagesFiles = self::$oldExtMsgFiles;
-               Language::getLocalisationCache()->recache( 'en' );
+               Language::clearCaches();
 
                parent::tearDownAfterClass();
        }
index c7a3fe6..1e063dd 100644 (file)
@@ -63,7 +63,7 @@ class DefaultPreferencesFactoryTest extends \MediaWikiTestCase {
 
                return new DefaultPreferencesFactory(
                        new LoggedServiceOptions( self::$serviceOptionsAccessLog,
-                               DefaultPreferencesFactory::$constructorOptions, $this->config ),
+                               DefaultPreferencesFactory::CONSTRUCTOR_OPTIONS, $this->config ),
                        new Language(),
                        AuthManager::singleton(),
                        MediaWikiServices::getInstance()->getLinkRenderer(),
index b5677fa..7acaa06 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest;
 use MediaWiki\Block\DatabaseBlock;
 use MediaWiki\Block\CompositeBlock;
 use MediaWiki\Block\SystemBlock;
@@ -14,14 +15,8 @@ use Wikimedia\Rdbms\ILoadBalancer;
  * @group Database
  */
 class PasswordResetTest extends MediaWikiTestCase {
-       private function makeConfig( $enableEmail, array $passwordResetRoutes = [] ) {
-               $hash = new HashConfig( [
-                       'EnableEmail' => $enableEmail,
-                       'PasswordResetRoutes' => $passwordResetRoutes,
-               ] );
-
-               return new ServiceOptions( PasswordReset::$constructorOptions, $hash );
-       }
+       const VALID_IP = '1.2.3.4';
+       const VALID_EMAIL = 'foo@bar.baz';
 
        /**
         * @dataProvider provideIsAllowed
@@ -29,7 +24,7 @@ class PasswordResetTest extends MediaWikiTestCase {
        public function testIsAllowed( $passwordResetRoutes, $enableEmail,
                $allowsAuthenticationDataChange, $canEditPrivate, $block, $globalBlock, $isAllowed
        ) {
-               $config = $this->makeConfig( $enableEmail, $passwordResetRoutes );
+               $config = $this->makeConfig( $enableEmail, $passwordResetRoutes, false );
 
                $authManager = $this->getMockBuilder( AuthManager::class )->disableOriginalConstructor()
                        ->getMock();
@@ -199,69 +194,429 @@ class PasswordResetTest extends MediaWikiTestCase {
                ];
        }
 
-       public function testExecute_email() {
-               $config = $this->makeConfig( true, [ 'username' => true, 'email' => true ] );
+       /**
+        * @expectedException \LogicException
+        */
+       public function testExecute_notAllowed() {
+               $user = $this->getMock( User::class );
+               /** @var User $user */
+
+               $passwordReset = $this->getMockBuilder( PasswordReset::class )
+                       ->disableOriginalConstructor()
+                       ->setMethods( [ 'isAllowed' ] )
+                       ->getMock();
+               $passwordReset->expects( $this->any() )
+                       ->method( 'isAllowed' )
+                       ->with( $user )
+                       ->willReturn( Status::newFatal( 'somestatuscode' ) );
+               /** @var PasswordReset $passwordReset */
+
+               $passwordReset->execute( $user );
+       }
 
+       /**
+        * @dataProvider provideExecute
+        * @param string|bool $expectedError
+        * @param ServiceOptions $config
+        * @param User $performingUser
+        * @param PermissionManager $permissionManager
+        * @param AuthManager $authManager
+        * @param string|null $username
+        * @param string|null $email
+        * @param User[] $usersWithEmail
+        */
+       public function testExecute(
+               $expectedError,
+               ServiceOptions $config,
+               User $performingUser,
+               PermissionManager $permissionManager,
+               AuthManager $authManager,
+               $username = '',
+               $email = '',
+               array $usersWithEmail = []
+       ) {
                // Unregister the hooks for proper unit testing
                $this->mergeMwGlobalArrayValue( 'wgHooks', [
                        'User::mailPasswordInternal' => [],
                        'SpecialPasswordResetOnSubmit' => [],
                ] );
 
-               $authManager = $this->getMockBuilder( AuthManager::class )->disableOriginalConstructor()
+               $loadBalancer = $this->getMockBuilder( ILoadBalancer::class )
                        ->getMock();
-               $authManager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' )
+
+               $users = $this->makeUsers();
+
+               $lookupUser = function ( $username ) use ( $users ) {
+                       return $users[ $username ] ?? false;
+               };
+
+               $passwordReset = $this->getMockBuilder( PasswordReset::class )
+                       ->setMethods( [ 'getUsersByEmail', 'isAllowed', 'lookupUser' ] )
+                       ->setConstructorArgs( [
+                               $config,
+                               $authManager,
+                               $permissionManager,
+                               $loadBalancer,
+                               new NullLogger()
+                       ] )
+                       ->getMock();
+               $passwordReset->method( 'getUsersByEmail' )->with( $email )
+                       ->willReturn( array_map( $lookupUser, $usersWithEmail ) );
+               $passwordReset->method( 'isAllowed' )
                        ->willReturn( Status::newGood() );
-               $authManager->expects( $this->exactly( 2 ) )->method( 'changeAuthenticationData' );
+               $passwordReset->method( 'lookupUser' )
+                       ->willReturnCallback( $lookupUser );
 
-               $permissionManager = $this->getMockBuilder( PermissionManager::class )
-                       ->disableOriginalConstructor()
+               /** @var PasswordReset $passwordReset */
+               $status = $passwordReset->execute( $performingUser, $username, $email );
+               $this->assertStatus( $status, $expectedError );
+       }
+
+       public function provideExecute() {
+               $defaultConfig = $this->makeConfig( true, [ 'username' => true, 'email' => true ], false );
+               $emailRequiredConfig = $this->makeConfig( true, [ 'username' => true, 'email' => true ], true );
+               $performingUser = $this->makePerformingUser( self::VALID_IP, false );
+               $throttledUser = $this->makePerformingUser( self::VALID_IP, true );
+               $permissionManager = $this->makePermissionManager( $performingUser, true );
+
+               return [
+                       'Invalid email' => [
+                               'expectedError' => 'passwordreset-invalidemail',
+                               'config' => $defaultConfig,
+                               'performingUser' => $throttledUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => '',
+                               'email' => '[invalid email]',
+                               'usersWithEmail' => [],
+                       ],
+                       'No username, no email' => [
+                               'expectedError' => 'passwordreset-nodata',
+                               'config' => $defaultConfig,
+                               'performingUser' => $throttledUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => '',
+                               'email' => '',
+                               'usersWithEmail' => [],
+                       ],
+                       'Email route not enabled' => [
+                               'expectedError' => 'passwordreset-nodata',
+                               'config' => $this->makeConfig( true, [ 'username' => true ], false ),
+                               'performingUser' => $throttledUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => '',
+                               'email' => self::VALID_EMAIL,
+                               'usersWithEmail' => [],
+                       ],
+                       'Username route not enabled' => [
+                               'expectedError' => 'passwordreset-nodata',
+                               'config' => $this->makeConfig( true, [ 'email' => true ], false ),
+                               'performingUser' => $throttledUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => 'User1',
+                               'email' => '',
+                               'usersWithEmail' => [],
+                       ],
+                       'No routes enabled' => [
+                               'expectedError' => 'passwordreset-nodata',
+                               'config' => $this->makeConfig( true, [], false ),
+                               'performingUser' => $throttledUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => 'User1',
+                               'email' => self::VALID_EMAIL,
+                               'usersWithEmail' => [],
+                       ],
+                       'Email reqiured for resets, but is empty' => [
+                               'expectedError' => 'passwordreset-username-email-required',
+                               'config' => $emailRequiredConfig,
+                               'performingUser' => $throttledUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => 'User1',
+                               'email' => '',
+                               'usersWithEmail' => [],
+                       ],
+                       'Email reqiured for resets, is invalid' => [
+                               'expectedError' => 'passwordreset-invalidemail',
+                               'config' => $emailRequiredConfig,
+                               'performingUser' => $throttledUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => 'User1',
+                               'email' => '[invalid email]',
+                               'usersWithEmail' => [],
+                       ],
+                       'Throttled' => [
+                               'expectedError' => 'actionthrottledtext',
+                               'config' => $defaultConfig,
+                               'performingUser' => $throttledUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => 'User1',
+                               'email' => '',
+                               'usersWithEmail' => [],
+                       ],
+                       'No user by this username' => [
+                               'expectedError' => 'nosuchuser',
+                               'config' => $defaultConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => 'Nonexistent user',
+                               'email' => '',
+                               'usersWithEmail' => [],
+                       ],
+                       'If no users with this email found, pretend everything is OK' => [
+                               'expectedError' => false,
+                               'config' => $defaultConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => '',
+                               'email' => 'some@not.found.email',
+                               'usersWithEmail' => [],
+                       ],
+                       'No email for the user' => [
+                               'expectedError' => 'noemail',
+                               'config' => $defaultConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => 'BadUser',
+                               'email' => '',
+                               'usersWithEmail' => [],
+                       ],
+                       'Email reqiured for resets, no match' => [
+                               'expectedError' => false,
+                               'config' => $emailRequiredConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => 'User1',
+                               'email' => 'some@other.email',
+                               'usersWithEmail' => [],
+                       ],
+                       "Couldn't determine the performing user's IP" => [
+                               'expectedError' => 'badipaddress',
+                               'config' => $defaultConfig,
+                               'performingUser' => $this->makePerformingUser( null, false ),
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => 'User1',
+                               'email' => '',
+                               'usersWithEmail' => [],
+                       ],
+                       'User is allowed, but ignored' => [
+                               'expectedError' => 'passwordreset-ignored',
+                               'config' => $defaultConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager( [ 'User1' ], 0, [ 'User1' ] ),
+                               'username' => 'User1',
+                               'email' => '',
+                               'usersWithEmail' => [],
+                       ],
+                       'One of users is ignored' => [
+                               'expectedError' => 'passwordreset-ignored',
+                               'config' => $defaultConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager( [ 'User1', 'User2' ], 0, [ 'User2' ] ),
+                               'username' => '',
+                               'email' => self::VALID_EMAIL,
+                               'usersWithEmail' => [ 'User1', 'User2' ],
+                       ],
+                       'User is rejected' => [
+                               'expectedError' => 'rejected by test mock',
+                               'config' => $defaultConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager(),
+                               'username' => 'User1',
+                               'email' => '',
+                               'usersWithEmail' => [],
+                       ],
+                       'One of users is rejected' => [
+                               'expectedError' => 'rejected by test mock',
+                               'config' => $defaultConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager( [ 'User1' ] ),
+                               'username' => '',
+                               'email' => self::VALID_EMAIL,
+                               'usersWithEmail' => [ 'User1', 'User2' ],
+                       ],
+                       'Reset one user via password' => [
+                               'expectedError' => false,
+                               'config' => $defaultConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager( [ 'User1' ], 1 ),
+                               'username' => 'User1',
+                               'email' => self::VALID_EMAIL,
+                               // Make sure that only the user specified by username is reset
+                               'usersWithEmail' => [ 'User1', 'User2' ],
+                       ],
+                       'Reset one user via email' => [
+                               'expectedError' => false,
+                               'config' => $defaultConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager( [ 'User1' ], 1 ),
+                               'username' => '',
+                               'email' => self::VALID_EMAIL,
+                               'usersWithEmail' => [ 'User1' ],
+                       ],
+                       'Reset multiple users via email' => [
+                               'expectedError' => false,
+                               'config' => $defaultConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager( [ 'User1', 'User2' ], 2 ),
+                               'username' => '',
+                               'email' => self::VALID_EMAIL,
+                               'usersWithEmail' => [ 'User1', 'User2' ],
+                       ],
+                       "Email is required for resets, user didn't opt in" => [
+                               'expectedError' => false,
+                               'config' => $emailRequiredConfig,
+                               'performingUser' => $performingUser,
+                               'permissionManager' => $permissionManager,
+                               'authManager' => $this->makeAuthManager( [ 'User2' ], 1 ),
+                               'username' => 'User2',
+                               'email' => self::VALID_EMAIL,
+                               'usersWithEmail' => [ 'User2' ],
+                       ],
+               ];
+       }
+
+       private function assertStatus( StatusValue $status, $error = false ) {
+               if ( $error === false ) {
+                       $this->assertTrue( $status->isGood(), 'Expected status to be good' );
+               } else {
+                       $this->assertFalse( $status->isGood(), 'Expected status to not be good' );
+                       if ( is_string( $error ) ) {
+                               $this->assertNotEmpty( $status->getErrors() );
+                               $message = $status->getErrors()[0]['message'];
+                               if ( $message instanceof MessageSpecifier ) {
+                                       $message = $message->getKey();
+                               }
+                               $this->assertSame( $error, $message );
+                       }
+               }
+       }
+
+       private function makeConfig( $enableEmail, array $passwordResetRoutes, $emailForResets ) {
+               $hash = [
+                       'AllowRequiringEmailForResets' => $emailForResets,
+                       'EnableEmail' => $enableEmail,
+                       'PasswordResetRoutes' => $passwordResetRoutes,
+               ];
+
+               return new ServiceOptions( PasswordReset::CONSTRUCTOR_OPTIONS, $hash );
+       }
+
+       /**
+        * @param string|null $ip
+        * @param bool $pingLimited
+        * @return User
+        */
+       private function makePerformingUser( $ip, $pingLimited ) : User {
+               $request = $this->getMockBuilder( WebRequest::class )
                        ->getMock();
-               $permissionManager->method( 'userHasRight' )->willReturn( true );
+               $request->method( 'getIP' )
+                       ->willReturn( $ip );
+               /** @var WebRequest $request */
 
-               $loadBalancer = $this->getMockBuilder( ILoadBalancer::class )
+               $user = $this->getMockBuilder( User::class )
+                       ->setMethods( [ 'getName', 'pingLimiter', 'getRequest' ] )
                        ->getMock();
 
-               $request = new FauxRequest();
-               $request->setIP( '1.2.3.4' );
-               $performingUser = $this->getMockBuilder( User::class )->getMock();
-               $performingUser->expects( $this->any() )->method( 'getRequest' )->willReturn( $request );
-               $performingUser->expects( $this->any() )->method( 'getName' )->willReturn( 'Performer' );
+               $user->method( 'getName' )
+                       ->willReturn( 'SomeUser' );
+               $user->method( 'pingLimiter' )
+                       ->with( 'mailpassword' )
+                       ->willReturn( $pingLimited );
+               $user->method( 'getRequest' )
+                       ->willReturn( $request );
+
+               /** @var User $user */
+               return $user;
+       }
 
+       private function makePermissionManager( User $performingUser, $isAllowed ) : PermissionManager {
                $permissionManager = $this->getMockBuilder( PermissionManager::class )
                        ->disableOriginalConstructor()
                        ->getMock();
-               $permissionManager->expects( $this->once() )
-                       ->method( 'userHasRight' )
+               $permissionManager->method( 'userHasRight' )
                        ->with( $performingUser, 'editmyprivateinfo' )
-                       ->willReturn( true );
+                       ->willReturn( $isAllowed );
 
-               $targetUser1 = $this->getMockBuilder( User::class )->getMock();
-               $targetUser2 = $this->getMockBuilder( User::class )->getMock();
-               $targetUser1->expects( $this->any() )->method( 'getName' )->willReturn( 'User1' );
-               $targetUser2->expects( $this->any() )->method( 'getName' )->willReturn( 'User2' );
-               $targetUser1->expects( $this->any() )->method( 'getId' )->willReturn( 1 );
-               $targetUser2->expects( $this->any() )->method( 'getId' )->willReturn( 2 );
-               $targetUser1->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' );
-               $targetUser2->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' );
+               /** @var PermissionManager $permissionManager */
+               return $permissionManager;
+       }
 
-               $passwordReset = $this->getMockBuilder( PasswordReset::class )
-                       ->setMethods( [ 'getUsersByEmail' ] )
-                       ->setConstructorArgs( [
-                               $config,
-                               $authManager,
-                               $permissionManager,
-                               $loadBalancer,
-                               new NullLogger()
-                       ] )
+       /**
+        * @param string[] $allowed
+        * @param int $numUsersToAuth
+        * @param string[] $ignored
+        * @return AuthManager
+        */
+       private function makeAuthManager(
+               array $allowed = [],
+               $numUsersToAuth = 0,
+               array $ignored = []
+       ) : AuthManager {
+               $authManager = $this->getMockBuilder( AuthManager::class )
+                       ->disableOriginalConstructor()
                        ->getMock();
-               $passwordReset->expects( $this->any() )->method( 'getUsersByEmail' )->with( 'foo@bar.baz' )
-                       ->willReturn( [ $targetUser1, $targetUser2 ] );
+               $authManager->method( 'allowsAuthenticationDataChange' )
+                       ->willReturnCallback(
+                               function ( TemporaryPasswordAuthenticationRequest $req ) use ( $allowed, $ignored ) {
+                                       $value = in_array( $req->username, $ignored, true )
+                                               ? 'ignored'
+                                               : 'okie dokie';
+                                       return in_array( $req->username, $allowed, true )
+                                               ? Status::newGood( $value )
+                                               : Status::newFatal( 'rejected by test mock' );
+                               } );
+               $authManager->expects( $this->exactly( $numUsersToAuth ) )
+                       ->method( 'changeAuthenticationData' );
+
+               /** @var AuthManager $authManager */
+               return $authManager;
+       }
+
+       /**
+        * @return User[]
+        */
+       private function makeUsers() {
+               $user1 = $this->getMockBuilder( User::class )->getMock();
+               $user2 = $this->getMockBuilder( User::class )->getMock();
+               $user1->method( 'getName' )->willReturn( 'User1' );
+               $user2->method( 'getName' )->willReturn( 'User2' );
+               $user1->method( 'getId' )->willReturn( 1 );
+               $user2->method( 'getId' )->willReturn( 2 );
+               $user1->method( 'getEmail' )->willReturn( self::VALID_EMAIL );
+               $user2->method( 'getEmail' )->willReturn( self::VALID_EMAIL );
+
+               $user1->method( 'getBoolOption' )
+                       ->with( 'requireemail' )
+                       ->willReturn( true );
 
-               $status = $passwordReset->isAllowed( $performingUser );
-               $this->assertTrue( $status->isGood() );
+               $badUser = $this->getMockBuilder( User::class )->getMock();
+               $badUser->method( 'getName' )->willReturn( 'BadUser' );
+               $badUser->method( 'getId' )->willReturn( 3 );
+               $badUser->method( 'getEmail' )->willReturn( null );
 
-               $status = $passwordReset->execute( $performingUser, null, 'foo@bar.baz' );
-               $this->assertTrue( $status->isGood() );
+               return [
+                       'User1' => $user1,
+                       'User2' => $user2,
+                       'BadUser' => $badUser,
+               ];
        }
 }
index 628d248..c443f20 100644 (file)
@@ -1812,12 +1812,6 @@ class LanguageTest extends LanguageClassesTestCase {
        public function testClearCaches() {
                $languageClass = TestingAccessWrapper::newFromClass( Language::class );
 
-               // Populate $dataCache
-               Language::getLocalisationCache()->getItem( 'zh', 'mainpage' );
-               $oldCacheObj = Language::$dataCache;
-               $this->assertNotCount( 0,
-                       TestingAccessWrapper::newFromObject( Language::$dataCache )->loadedItems );
-
                // Populate $mLangObjCache
                $lang = Language::factory( 'en' );
                $this->assertNotCount( 0, Language::$mLangObjCache );
@@ -1836,9 +1830,6 @@ class LanguageTest extends LanguageClassesTestCase {
 
                Language::clearCaches();
 
-               $this->assertNotSame( $oldCacheObj, Language::$dataCache );
-               $this->assertCount( 0,
-                       TestingAccessWrapper::newFromObject( Language::$dataCache )->loadedItems );
                $this->assertCount( 0, Language::$mLangObjCache );
                $this->assertCount( 0, $languageClass->fallbackLanguageCache );
                $this->assertNull( $languageClass->grammarTransformations );
index 02042b8..5d53c30 100644 (file)
@@ -13,10 +13,6 @@ class ResourceLoaderImageTest extends MediaWikiUnitTestCase {
                $this->imagesPath = __DIR__ . '/../../../data/resourceloader';
        }
 
-       protected function tearDown() {
-               Language::$dataCache = null;
-       }
-
        protected function getTestImage( $name ) {
                $options = ResourceLoaderImageModuleTest::$commonImageData[$name];
                $fileDescriptor = is_string( $options ) ? $options : $options['file'];
@@ -57,6 +53,7 @@ class ResourceLoaderImageTest extends MediaWikiUnitTestCase {
         * @dataProvider provideGetPath
         */
        public function testGetPath( $imageName, $languageCode, $path ) {
+               $this->markTestSkipped( 'Depends on overriding LanguageFallback/LocalisationCache' );
                static $dirMap = [
                        'en' => 'ltr',
                        'en-gb' => 'ltr',