Merge "Update OOUI to v0.27.6"
authorBartosz Dziewoński <matma.rex@gmail.com>
Thu, 2 Aug 2018 18:17:03 +0000 (18:17 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Thu, 2 Aug 2018 18:17:03 +0000 (18:17 +0000)
43 files changed:
.travis.yml
RELEASE-NOTES-1.32
autoload.php
includes/MediaWikiServices.php
includes/OutputPage.php
includes/ServiceWiring.php
includes/Setup.php
includes/api/ApiQuerySiteinfo.php
includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php
includes/changes/ChangesList.php
includes/logging/LogFormatter.php
includes/parser/Parser.php
includes/password/PasswordFactory.php
includes/specials/SpecialBotPasswords.php
includes/user/BotPassword.php
languages/Language.php
maintenance/deleteLocalPasswords.php [new file with mode: 0644]
maintenance/includes/DeleteLocalPasswords.php [new file with mode: 0644]
maintenance/rebuildrecentchanges.php
maintenance/wrapOldPasswords.php
tests/parser/ParserTestRunner.php
tests/phpunit/MediaWikiTestCase.php
tests/phpunit/includes/EditPageTest.php
tests/phpunit/includes/MediaWikiServicesTest.php
tests/phpunit/includes/MessageTest.php
tests/phpunit/includes/OutputPageTest.php
tests/phpunit/includes/PagePropsTest.php
tests/phpunit/includes/PrefixSearchTest.php
tests/phpunit/includes/RevisionDbTestBase.php
tests/phpunit/includes/TestUser.php
tests/phpunit/includes/TitleMethodsTest.php
tests/phpunit/includes/api/ApiEditPageTest.php
tests/phpunit/includes/api/ApiLoginTest.php
tests/phpunit/includes/api/ApiQuerySiteinfoTest.php [new file with mode: 0644]
tests/phpunit/includes/api/ApiTestCase.php
tests/phpunit/includes/auth/AuthManagerTest.php
tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php
tests/phpunit/includes/content/ContentHandlerTest.php
tests/phpunit/includes/media/ExifRotationTest.php
tests/phpunit/includes/password/PasswordFactoryTest.php
tests/phpunit/includes/session/BotPasswordSessionProviderTest.php
tests/phpunit/includes/user/BotPasswordTest.php
tests/phpunit/languages/LanguageTest.php

index 2fc6d64..ad35b76 100644 (file)
@@ -39,7 +39,6 @@ matrix:
       php: hhvm-3.18
   allow_failures:
     - php: 7.2
-    - env: dbtype=postgres dbuser=travis
     - php: hhvm-3.18
     - php: hhvm-3.21
     - php: hhvm-3.24
index 006476f..ae7f68c 100644 (file)
@@ -337,6 +337,8 @@ because of Phabricator reports.
 * The wfUseMW function, soft-deprecated in 1.26, is now hard deprecated.
 * All MagicWord static methods are now deprecated.  Use the MagicWordFactory
   methods instead.
+* PasswordFactory::init is deprecated. To get a password factory with the
+  standard configuration, use MediaWikiServices::getPasswordFactory.
 
 === Other changes in 1.32 ===
 * (T198811) The following tables have had their UNIQUE indexes turned into
index b5b5c52..38f6ba9 100644 (file)
@@ -378,6 +378,7 @@ $wgAutoloadLocalClasses = [
        'DeleteEqualMessages' => __DIR__ . '/maintenance/deleteEqualMessages.php',
        'DeleteFileOp' => __DIR__ . '/includes/libs/filebackend/fileop/DeleteFileOp.php',
        'DeleteLinksJob' => __DIR__ . '/includes/jobqueue/jobs/DeleteLinksJob.php',
+       'DeleteLocalPasswords' => __DIR__ . '/maintenance/includes/DeleteLocalPasswords.php',
        'DeleteLogFormatter' => __DIR__ . '/includes/logging/DeleteLogFormatter.php',
        'DeleteOldRevisions' => __DIR__ . '/maintenance/deleteOldRevisions.php',
        'DeleteOrphanedRevisions' => __DIR__ . '/maintenance/deleteOrphanedRevisions.php',
index f891042..472a453 100644 (file)
@@ -38,6 +38,7 @@ use MimeAnalyzer;
 use ObjectCache;
 use Parser;
 use ParserCache;
+use PasswordFactory;
 use ProxyLookup;
 use SearchEngine;
 use SearchEngineConfig;
@@ -873,6 +874,22 @@ class MediaWikiServices extends ServiceContainer {
                return $this->getService( 'MagicWordFactory' );
        }
 
+       /**
+        * @since 1.32
+        * @return \Language
+        */
+       public function getContentLanguage() {
+               return $this->getService( 'ContentLanguage' );
+       }
+
+       /**
+        * @since 1.32
+        * @return PasswordFactory
+        */
+       public function getPasswordFactory() {
+               return $this->getService( 'PasswordFactory' );
+       }
+
        ///////////////////////////////////////////////////////////////////////////
        // NOTE: When adding a service getter here, don't forget to add a test
        // case for it in MediaWikiServicesTest::provideGetters() and in
index aa517e7..9173f26 100644 (file)
@@ -71,7 +71,7 @@ class OutputPage extends ContextSource {
         * @var bool Is the displayed content related to the source of the
         *   corresponding wiki article.
         */
-       private $mIsarticle = false;
+       private $mIsArticle = false;
 
        /** @var bool Stores "article flag" toggle. */
        private $mIsArticleRelated = true;
@@ -1164,12 +1164,12 @@ class OutputPage extends ContextSource {
         * corresponding article on the wiki
         * Setting true will cause the change "article related" toggle to true
         *
-        * @param bool $v
+        * @param bool $newVal
         */
-       public function setArticleFlag( $v ) {
-               $this->mIsarticle = $v;
-               if ( $v ) {
-                       $this->mIsArticleRelated = $v;
+       public function setArticleFlag( $newVal ) {
+               $this->mIsArticle = $newVal;
+               if ( $newVal ) {
+                       $this->mIsArticleRelated = $newVal;
                }
        }
 
@@ -1180,19 +1180,19 @@ class OutputPage extends ContextSource {
         * @return bool
         */
        public function isArticle() {
-               return $this->mIsarticle;
+               return $this->mIsArticle;
        }
 
        /**
         * Set whether this page is related an article on the wiki
         * Setting false will cause the change of "article flag" toggle to false
         *
-        * @param bool $v
+        * @param bool $newVal
         */
-       public function setArticleRelated( $v ) {
-               $this->mIsArticleRelated = $v;
-               if ( !$v ) {
-                       $this->mIsarticle = false;
+       public function setArticleRelated( $newVal ) {
+               $this->mIsArticleRelated = $newVal;
+               if ( !$newVal ) {
+                       $this->mIsArticle = false;
                }
        }
 
@@ -1242,7 +1242,7 @@ class OutputPage extends ContextSource {
        public function addCategoryLinks( array $categories ) {
                global $wgContLang;
 
-               if ( !is_array( $categories ) || count( $categories ) == 0 ) {
+               if ( !$categories ) {
                        return;
                }
 
@@ -1579,12 +1579,12 @@ class OutputPage extends ContextSource {
         * Set the revision ID which will be seen by the wiki text parser
         * for things such as embedded {{REVISIONID}} variable use.
         *
-        * @param int|null $revid An positive integer, or null
+        * @param int|null $revid A positive integer, or null
         * @return mixed Previous value
         */
        public function setRevisionId( $revid ) {
                $val = is_null( $revid ) ? null : intval( $revid );
-               return wfSetVar( $this->mRevisionId, $val );
+               return wfSetVar( $this->mRevisionId, $val, true );
        }
 
        /**
@@ -1604,7 +1604,7 @@ class OutputPage extends ContextSource {
         * @return mixed Previous value
         */
        public function setRevisionTimestamp( $timestamp ) {
-               return wfSetVar( $this->mRevisionTimestamp, $timestamp );
+               return wfSetVar( $this->mRevisionTimestamp, $timestamp, true );
        }
 
        /**
@@ -1620,7 +1620,7 @@ class OutputPage extends ContextSource {
        /**
         * Set the displayed file version
         *
-        * @param File|bool $file
+        * @param File|null $file
         * @return mixed Previous value
         */
        public function setFileVersion( $file ) {
@@ -1681,10 +1681,10 @@ class OutputPage extends ContextSource {
         * Add wikitext with a custom Title object
         *
         * @param string $text Wikitext
-        * @param Title &$title
+        * @param Title $title
         * @param bool $linestart Is this the start of a line?
         */
-       public function addWikiTextWithTitle( $text, &$title, $linestart = true ) {
+       public function addWikiTextWithTitle( $text, Title $title, $linestart = true ) {
                $this->addWikiTextTitle( $text, $title, $linestart );
        }
 
@@ -1692,10 +1692,10 @@ class OutputPage extends ContextSource {
         * Add wikitext with a custom Title object and tidy enabled.
         *
         * @param string $text Wikitext
-        * @param Title &$title
+        * @param Title $title
         * @param bool $linestart Is this the start of a line?
         */
-       function addWikiTextTitleTidy( $text, &$title, $linestart = true ) {
+       function addWikiTextTitleTidy( $text, Title $title, $linestart = true ) {
                $this->addWikiTextTitle( $text, $title, $linestart, true );
        }
 
index 66b4c0c..853b06d 100644 (file)
@@ -115,10 +115,9 @@ return [
        },
 
        'InterwikiLookup' => function ( MediaWikiServices $services ) {
-               global $wgContLang; // TODO: manage $wgContLang as a service
                $config = $services->getMainConfig();
                return new ClassicInterwikiLookup(
-                       $wgContLang,
+                       $services->getContentLanguage(),
                        $services->getMainWANObjectCache(),
                        $config->get( 'InterwikiExpiry' ),
                        $config->get( 'InterwikiCache' ),
@@ -150,8 +149,8 @@ return [
        },
 
        'SearchEngineConfig' => function ( MediaWikiServices $services ) {
-               global $wgContLang;
-               return new SearchEngineConfig( $services->getMainConfig(), $wgContLang );
+               return new SearchEngineConfig( $services->getMainConfig(),
+                       $services->getContentLanguage() );
        },
 
        'SkinFactory' => function ( MediaWikiServices $services ) {
@@ -345,10 +344,8 @@ return [
        },
 
        '_MediaWikiTitleCodec' => function ( MediaWikiServices $services ) {
-               global $wgContLang;
-
                return new MediaWikiTitleCodec(
-                       $wgContLang,
+                       $services->getContentLanguage(),
                        $services->getGenderCache(),
                        $services->getMainConfig()->get( 'LocalInterwikis' )
                );
@@ -508,12 +505,11 @@ return [
        },
 
        'BlobStoreFactory' => function ( MediaWikiServices $services ) {
-               global $wgContLang;
                return new BlobStoreFactory(
                        $services->getDBLoadBalancer(),
                        $services->getMainWANObjectCache(),
                        $services->getMainConfig(),
-                       $wgContLang
+                       $services->getContentLanguage()
                );
        },
 
@@ -573,11 +569,11 @@ return [
        },
 
        'PreferencesFactory' => function ( MediaWikiServices $services ) {
-               global $wgContLang;
                $authManager = AuthManager::singleton();
                $linkRenderer = $services->getLinkRendererFactory()->create();
                $config = $services->getMainConfig();
-               $factory = new DefaultPreferencesFactory( $config, $wgContLang, $authManager, $linkRenderer );
+               $factory = new DefaultPreferencesFactory( $config, $services->getContentLanguage(),
+                       $authManager, $linkRenderer );
                $factory->setLogger( LoggerFactory::getInstance( 'preferences' ) );
 
                return $factory;
@@ -588,9 +584,8 @@ return [
        },
 
        'CommentStore' => function ( MediaWikiServices $services ) {
-               global $wgContLang;
                return new CommentStore(
-                       $wgContLang,
+                       $services->getContentLanguage(),
                        $services->getMainConfig()->get( 'CommentTableSchemaMigrationStage' )
                );
        },
@@ -602,8 +597,19 @@ return [
        },
 
        'MagicWordFactory' => function ( MediaWikiServices $services ) {
-               global $wgContLang;
-               return new MagicWordFactory( $wgContLang );
+               return new MagicWordFactory( $services->getContentLanguage() );
+       },
+
+       'ContentLanguage' => function ( MediaWikiServices $services ) {
+               return Language::factory( $services->getMainConfig()->get( 'LanguageCode' ) );
+       },
+
+       'PasswordFactory' => function ( MediaWikiServices $services ) {
+               $config = $services->getMainConfig();
+               return new PasswordFactory(
+                       $config->get( 'PasswordConfig' ),
+                       $config->get( 'PasswordDefault' )
+               );
        },
 
        ///////////////////////////////////////////////////////////////////////////
index 4d9c495..9923ae2 100644 (file)
@@ -794,9 +794,9 @@ $ps_globals = Profiler::instance()->scopedProfileIn( $fname . '-globals' );
 
 /**
  * @var Language $wgContLang
+ * @deprecated since 1.32, use the ContentLanguage service directly
  */
-$wgContLang = Language::factory( $wgLanguageCode );
-$wgContLang->initContLang();
+$wgContLang = MediaWikiServices::getInstance()->getContentLanguage();
 
 // Now that variant lists may be available...
 $wgRequest->interpolateTitle();
index 7d4b55f..c65dfa5 100644 (file)
@@ -54,8 +54,7 @@ class ApiQuerySiteinfo extends ApiQueryBase {
                                        $fit = $this->appendMagicWords( $p );
                                        break;
                                case 'interwikimap':
-                                       $filteriw = $params['filteriw'] ?? false;
-                                       $fit = $this->appendInterwikiMap( $p, $filteriw );
+                                       $fit = $this->appendInterwikiMap( $p, $params['filteriw'] );
                                        break;
                                case 'dbrepllag':
                                        $fit = $this->appendDbReplLagInfo( $p, $params['showalldb'] );
@@ -112,7 +111,7 @@ class ApiQuerySiteinfo extends ApiQueryBase {
                                        $fit = $this->appendUploadDialog( $p );
                                        break;
                                default:
-                                       ApiBase::dieDebug( __METHOD__, "Unknown prop=$p" );
+                                       ApiBase::dieDebug( __METHOD__, "Unknown prop=$p" ); // @codeCoverageIgnore
                        }
                        if ( !$fit ) {
                                // Abuse siprop as a query-continue parameter
@@ -145,7 +144,7 @@ class ApiQuerySiteinfo extends ApiQueryBase {
                $data['phpversion'] = PHP_VERSION;
                $data['phpsapi'] = PHP_SAPI;
                if ( defined( 'HHVM_VERSION' ) ) {
-                       $data['hhvmversion'] = HHVM_VERSION;
+                       $data['hhvmversion'] = HHVM_VERSION; // @codeCoverageIgnore
                }
                $data['dbtype'] = $config->get( 'DBtype' );
                $data['dbversion'] = $this->getDB()->getServerVersion();
@@ -229,12 +228,6 @@ class ApiQuerySiteinfo extends ApiQueryBase {
 
                $tz = $config->get( 'Localtimezone' );
                $offset = $config->get( 'LocalTZoffset' );
-               if ( is_null( $tz ) ) {
-                       $tz = 'UTC';
-                       $offset = 0;
-               } elseif ( is_null( $offset ) ) {
-                       $offset = 0;
-               }
                $data['timezone'] = $tz;
                $data['timeoffset'] = intval( $offset );
                $data['articlepath'] = $config->get( 'ArticlePath' );
@@ -377,13 +370,13 @@ class ApiQuerySiteinfo extends ApiQueryBase {
        }
 
        protected function appendInterwikiMap( $property, $filter ) {
-               $local = null;
                if ( $filter === 'local' ) {
                        $local = 1;
                } elseif ( $filter === '!local' ) {
                        $local = 0;
-               } elseif ( $filter ) {
-                       ApiBase::dieDebug( __METHOD__, "Unknown filter=$filter" );
+               } else {
+                       // $filter === null
+                       $local = null;
                }
 
                $params = $this->extractRequestParams();
@@ -663,13 +656,13 @@ class ApiQuerySiteinfo extends ApiQueryBase {
                        $url = $config->get( 'RightsUrl' );
                }
                $text = $config->get( 'RightsText' );
-               if ( !$text && $title ) {
+               if ( $title && !strlen( $text ) ) {
                        $text = $title->getPrefixedText();
                }
 
                $data = [
-                       'url' => $url ?: '',
-                       'text' => $text ?: ''
+                       'url' => strlen( $url ) ? $url : '',
+                       'text' => strlen( $text ) ? $text : '',
                ];
 
                return $this->getResult()->addValue( 'query', $property, $data );
@@ -790,7 +783,12 @@ class ApiQuerySiteinfo extends ApiQueryBase {
        public function appendExtensionTags( $property ) {
                global $wgParser;
                $wgParser->firstCallInit();
-               $tags = array_map( [ $this, 'formatParserTags' ], $wgParser->getTags() );
+               $tags = array_map(
+                       function ( $item ) {
+                               return "<$item>";
+                       },
+                       $wgParser->getTags()
+               );
                ApiResult::setArrayType( $tags, 'BCarray' );
                ApiResult::setIndexedTagName( $tags, 't' );
 
@@ -835,10 +833,6 @@ class ApiQuerySiteinfo extends ApiQueryBase {
                return $this->getResult()->addValue( 'query', $property, $config );
        }
 
-       private function formatParserTags( $item ) {
-               return "<{$item}>";
-       }
-
        public function appendSubscribedHooks( $property ) {
                $hooks = $this->getConfig()->get( 'Hooks' );
                $myWgHooks = $hooks;
index 3d26767..4096f19 100644 (file)
@@ -53,8 +53,10 @@ abstract class AbstractPasswordPrimaryAuthenticationProvider
         */
        protected function getPasswordFactory() {
                if ( $this->passwordFactory === null ) {
-                       $this->passwordFactory = new PasswordFactory();
-                       $this->passwordFactory->init( $this->config );
+                       $this->passwordFactory = new PasswordFactory(
+                               $this->config->get( 'PasswordConfig' ),
+                               $this->config->get( 'PasswordDefault' )
+                       );
                }
                return $this->passwordFactory;
        }
index eb9febe..fea31b4 100644 (file)
@@ -531,10 +531,10 @@ class ChangesList extends ContextSource {
        public function getTimestamp( $rc ) {
                // @todo FIXME: Hard coded ". .". Is there a message for this? Should there be?
                return $this->message['semicolon-separator'] . '<span class="mw-changeslist-date">' .
-                       $this->getLanguage()->userTime(
+                       htmlspecialchars( $this->getLanguage()->userTime(
                                $rc->mAttribs['rc_timestamp'],
                                $this->getUser()
-                       ) . '</span> <span class="mw-changeslist-separator">. .</span> ';
+                       ) . '</span> <span class="mw-changeslist-separator">. .</span> ';
        }
 
        /**
index d59c6aa..35bb451 100644 (file)
@@ -189,6 +189,7 @@ class LogFormatter {
         * to avoid formatting for any particular user.
         * @see getActionText()
         * @return string Plain text
+        * @return-taint tainted
         */
        public function getPlainActionText() {
                $this->plaintext = true;
@@ -436,6 +437,8 @@ class LogFormatter {
        /**
         * Gets the log action, including username.
         * @return string HTML
+        * phan-taint-check gets very confused by $this->plaintext, so disable.
+        * @return-taint onlysafefor_html
         */
        public function getActionText() {
                if ( $this->canView( LogPage::DELETED_ACTION ) ) {
@@ -702,6 +705,7 @@ class LogFormatter {
         * Helper method for displaying restricted element.
         * @param string $message
         * @return string HTML or wiki text
+        * @return-taint onlysafefor_html
         */
        protected function getRestrictedElement( $message ) {
                if ( $this->plaintext ) {
@@ -737,6 +741,12 @@ class LogFormatter {
                return $this->context->msg( $key );
        }
 
+       /**
+        * @param User $user
+        * @param int $toolFlags Combination of Linker::TOOL_LINKS_* flags
+        * @return string wikitext or html
+        * @return-taint onlysafefor_html
+        */
        protected function makeUserLink( User $user, $toolFlags = 0 ) {
                if ( $this->plaintext ) {
                        $element = $user->getName();
@@ -938,6 +948,10 @@ class LegacyLogFormatter extends LogFormatter {
                return $this->comment;
        }
 
+       /**
+        * @return string
+        * @return-taint onlysafefor_html
+        */
        protected function getActionMessage() {
                $entry = $this->entry;
                $action = LogPage::actionText(
index 2778ea6..bd7d4ac 100644 (file)
@@ -153,6 +153,9 @@ class Parser {
        public $mImageParams = [];
        public $mImageParamsMagicArray = [];
        public $mMarkerIndex = 0;
+       /**
+        * @var bool Whether firstCallInit still needs to be called
+        */
        public $mFirstCall = true;
 
        # Initialised by initialiseVariables()
@@ -354,9 +357,7 @@ class Parser {
         * @private
         */
        public function clearState() {
-               if ( $this->mFirstCall ) {
-                       $this->firstCallInit();
-               }
+               $this->firstCallInit();
                $this->mOutput = new ParserOutput;
                $this->mOptions->registerWatcher( [ $this->mOutput, 'recordOption' ] );
                $this->mAutonumber = 0;
index bc37b48..f7b283b 100644 (file)
@@ -36,6 +36,7 @@ final class PasswordFactory {
 
        /**
         * Mapping of password types to classes
+        *
         * @var array
         * @see PasswordFactory::register
         * @see Setup.php
@@ -44,11 +45,31 @@ final class PasswordFactory {
                '' => [ 'type' => '', 'class' => InvalidPassword::class ],
        ];
 
+       /**
+        * Construct a new password factory.
+        * Most of the time you'll want to use MediaWikiServices::getPasswordFactory instead.
+        * @param array $config Mapping of password type => config
+        * @param string $default Default password type
+        * @see PasswordFactory::register
+        * @see PasswordFactory::setDefaultType
+        */
+       public function __construct( array $config = [], $default = '' ) {
+               foreach ( $config as $type => $options ) {
+                       $this->register( $type, $options );
+               }
+
+               if ( $default !== '' ) {
+                       $this->setDefaultType( $default );
+               }
+       }
+
        /**
         * Register a new type of password hash
         *
-        * @param string $type Unique type name for the hash
-        * @param array $config Array of configuration options
+        * @param string $type Unique type name for the hash. Will be prefixed to the password hashes
+        *   to identify what hashing method was used.
+        * @param array $config Array of configuration options. 'class' is required (the Password
+        *   subclass name), everything else is passed to the constructor of that class.
         */
        public function register( $type, array $config ) {
                $config['type'] = $type;
@@ -58,8 +79,11 @@ final class PasswordFactory {
        /**
         * Set the default password type
         *
-        * @throws InvalidArgumentException If the type is not registered
+        * This type will be used for creating new passwords when the type is not specified.
+        * Passwords of a different type will be considered outdated and in need of update.
+        *
         * @param string $type Password hash type
+        * @throws InvalidArgumentException If the type is not registered
         */
        public function setDefaultType( $type ) {
                if ( !isset( $this->types[$type] ) ) {
@@ -78,6 +102,8 @@ final class PasswordFactory {
        }
 
        /**
+        * @deprecated since 1.32 Initialize settings using the constructor
+        *
         * Initialize the internal static variables using the global variables
         *
         * @param Config $config Configuration object to load data from
index 2d3a0cc..2d62d8f 100644 (file)
@@ -22,6 +22,7 @@
  */
 
 use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
 
 /**
  * Let users manage bot passwords
@@ -166,8 +167,7 @@ class SpecialBotPasswords extends FormSpecialPage {
 
                } else {
                        $linkRenderer = $this->getLinkRenderer();
-                       $passwordFactory = new PasswordFactory();
-                       $passwordFactory->init( $this->getConfig() );
+                       $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
 
                        $dbr = BotPassword::getDB( DB_REPLICA );
                        $res = $dbr->select(
@@ -321,8 +321,7 @@ class SpecialBotPasswords extends FormSpecialPage {
 
                if ( $this->operation === 'insert' || !empty( $data['resetPassword'] ) ) {
                        $this->password = BotPassword::generatePassword( $this->getConfig() );
-                       $passwordFactory = new PasswordFactory();
-                       $passwordFactory->init( RequestContext::getMain()->getConfig() );
+                       $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
                        $password = $passwordFactory->newFromPlaintext( $this->password );
                } else {
                        $password = null;
index 960a486..2228feb 100644 (file)
@@ -250,8 +250,7 @@ class BotPassword implements IDBAccessObject {
                        return PasswordFactory::newInvalidPassword();
                }
 
-               $passwordFactory = new \PasswordFactory();
-               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
                try {
                        return $passwordFactory->newFromCiphertext( $password );
                } catch ( PasswordError $ex ) {
index 7f04a68..453a610 100644 (file)
@@ -244,6 +244,24 @@ class Language {
                throw new MWException( "Invalid fallback sequence for language '$code'" );
        }
 
+       /**
+        * Intended for tests that may change configuration in a way that invalidates caches.
+        *
+        * @since 1.32
+        */
+       public static function clearCaches() {
+               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+                       throw new MWException( __METHOD__ . ' must not be used outside tests' );
+               }
+               self::$dataCache = null;
+               // Reinitialize $dataCache, since it's expected to always be available
+               self::getLocalisationCache();
+               self::$mLangObjCache = [];
+               self::$fallbackLanguageCache = [];
+               self::$grammarTransformations = null;
+               self::$languageNameCache = null;
+       }
+
        /**
         * Checks whether any localisation is available for that language tag
         * in MediaWiki (MessagesXx.php exists).
diff --git a/maintenance/deleteLocalPasswords.php b/maintenance/deleteLocalPasswords.php
new file mode 100644 (file)
index 0000000..8e54026
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+/**
+ * Delete unused local passwords.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+
+require_once __DIR__ . '/includes/DeleteLocalPasswords.php';
+
+$maintClass = DeleteLocalPasswords::class;
+require_once RUN_MAINTENANCE_IF_MAIN;
diff --git a/maintenance/includes/DeleteLocalPasswords.php b/maintenance/includes/DeleteLocalPasswords.php
new file mode 100644 (file)
index 0000000..e3f8926
--- /dev/null
@@ -0,0 +1,186 @@
+<?php
+/**
+ * Helper for deleting unused local passwords.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\IDatabase;
+
+require_once __DIR__ . '/../Maintenance.php';
+
+/**
+ * Delete unused local passwords.
+ *
+ * Mainly intended to be used as a base class by authentication extensions to provide maintenance
+ * scripts which allow deleting local passwords for users who have another way of logging in.
+ * Such scripts would customize how to locate users who have other login methods and don't need
+ * local login anymore.
+ * Make sure to set LocalPasswordPrimaryAuthenticationProvider to loginOnly => true or disable it
+ * completely before running this, otherwise it might recreate passwords.
+ *
+ * This class can also be used directly to just delete all local passwords, or those for a specific
+ * user. Deleting all passwords is useful when the wiki has used local password login in the past
+ * but it has been disabled.
+ */
+class DeleteLocalPasswords extends Maintenance {
+       /** @var string|null User to run on, or null for all. */
+       protected $user;
+
+       /** @var int Number of deleted passwords. */
+       protected $total;
+
+       public function __construct() {
+               parent::__construct();
+               $this->mDescription = "Deletes local password for users.";
+               $this->setBatchSize( 1000 );
+
+               $this->addOption( 'user', 'If specified, only checks the given user', false, true );
+               $this->addOption( 'delete', 'Really delete. To prevent accidents, you must provide this flag.' );
+               $this->addOption( 'prefix', "Instead of deleting, make passwords invalid by prefixing with "
+                       . "':null:'. Make sure PasswordConfig has a 'null' entry. This is meant for testing before "
+                       . "hard delete." );
+               $this->addOption( 'unprefix', 'Instead of deleting, undo the effect of --prefix.' );
+       }
+
+       protected function initialize() {
+               if (
+                       $this->hasOption( 'delete' ) + $this->hasOption( 'prefix' )
+                       + $this->hasOption( 'unprefix' ) !== 1
+               ) {
+                       $this->fatalError( "Exactly one of the 'delete', 'prefix', 'unprefix' options must be used\n" );
+               }
+               if ( $this->hasOption( 'prefix' ) || $this->hasOption( 'unprefix' ) ) {
+                       $passwordHashTypes = MediaWikiServices::getInstance()->getPasswordFactory()->getTypes();
+                       if (
+                               !isset( $passwordHashTypes['null'] )
+                               || $passwordHashTypes['null']['class'] !== InvalidPassword::class
+                       ) {
+                               $this->fatalError(
+<<<'ERROR'
+'null' password entry missing. To use password prefixing, add
+    $wgPasswordConfig['null'] = [ 'class' => InvalidPassword::class ];
+to your configuration (and remove once the passwords were deleted).
+ERROR
+                               );
+                       }
+               }
+
+               $user = $this->getOption( 'user', false );
+               if ( $user !== false ) {
+                       $this->user = User::getCanonicalName( $user );
+                       if ( $this->user === false ) {
+                               $this->fatalError( "Invalid user name\n" );
+                       }
+               }
+       }
+
+       public function execute() {
+               $this->initialize();
+
+               foreach ( $this->getUserBatches() as $userBatch ) {
+                       $this->processUsers( $userBatch, $this->getUserDB() );
+               }
+
+               $this->output( "done. (wrote $this->total rows)\n" );
+       }
+
+       /**
+        * Get the master DB handle for the current user batch. This is provided for the benefit
+        * of authentication extensions which subclass this and work with wiki farms.
+        */
+       protected function getUserDB() {
+               return $this->getDB( DB_MASTER );
+       }
+
+       protected function processUsers( array $userBatch, IDatabase $dbw ) {
+               if ( !$userBatch ) {
+                       return;
+               }
+               if ( $this->getOption( 'delete' ) ) {
+                       $dbw->update( 'user',
+                               [ 'user_password' => PasswordFactory::newInvalidPassword()->toString() ],
+                               [ 'user_name' => $userBatch ],
+                               __METHOD__
+                       );
+               } elseif ( $this->getOption( 'prefix' ) ) {
+                       $dbw->update( 'user',
+                               [ 'user_password = ' . $dbw->buildConcat( [ $dbw->addQuotes( ':null:' ),
+                                               'user_password' ] ) ],
+                               [
+                                       'NOT (user_password ' . $dbw->buildLike( ':null:', $dbw->anyString() ) . ')',
+                                       "user_password != " . $dbw->addQuotes( PasswordFactory::newInvalidPassword()->toString() ),
+                                       'user_password IS NOT NULL',
+                                       'user_name' => $userBatch,
+                               ],
+                               __METHOD__
+                       );
+               } elseif ( $this->getOption( 'unprefix' ) ) {
+                       $dbw->update( 'user',
+                               [ 'user_password = ' . $dbw->buildSubString( 'user_password', strlen( ':null:' ) + 1 ) ],
+                               [
+                                       'user_password ' . $dbw->buildLike( ':null:', $dbw->anyString() ),
+                                       'user_name' => $userBatch,
+                               ],
+                               __METHOD__
+                       );
+               }
+               $this->total += $dbw->affectedRows();
+       }
+
+       /**
+        * This method iterates through the requested users and returns their names in batches of
+        * self::$mBatchSize.
+        *
+        * Subclasses should reimplement this and locate users who use the specific authentication
+        * method. The default implementation just iterates through all users. Extensions that work
+        * with wikifarm should also update self::getUserDB() as necessary.
+        * @return Generator
+        */
+       protected function getUserBatches() {
+               if ( !is_null( $this->user ) ) {
+                       $this->output( "\t ... querying '$this->user'\n" );
+                       yield [ $this->user ];
+                       return;
+               }
+
+               $lastUsername = '';
+               $dbw = $this->getDB( DB_MASTER );
+               do {
+                       $this->output( "\t ... querying from '$lastUsername'\n" );
+                       $users = $dbw->selectFieldValues(
+                               'user',
+                               'user_name',
+                               [
+                                       'user_name > ' .$dbw->addQuotes( $lastUsername ),
+                               ],
+                               __METHOD__,
+                               [
+                                       'LIMIT' => $this->getBatchSize(),
+                                       'ORDER BY' => 'user_name ASC',
+                               ]
+                       );
+                       if ( $users ) {
+                               yield $users;
+                               $lastUsername = end( $users );
+                       }
+               } while ( count( $users ) === $this->getBatchSize() );
+       }
+}
index d86c8ed..471c7ae 100644 (file)
@@ -269,10 +269,13 @@ class RebuildRecentchanges extends Maintenance {
         * Rebuild pass 3: Insert `recentchanges` entries for action logs.
         */
        private function rebuildRecentChangesTablePass3( ILBFactory $lbFactory ) {
-               global $wgLogRestrictions;
+               global $wgLogRestrictions, $wgFilterLogTypes;
 
                $dbw = $this->getDB( DB_MASTER );
                $commentStore = CommentStore::getStore();
+               $nonRCLogs = array_merge( array_keys( $wgLogRestrictions ),
+                       array_keys( $wgFilterLogTypes ),
+                       [ 'create' ] );
 
                $this->output( "Loading from user, page, and logging tables...\n" );
 
@@ -294,9 +297,8 @@ class RebuildRecentchanges extends Maintenance {
                        [
                                'log_timestamp > ' . $dbw->addQuotes( $dbw->timestamp( $this->cutoffFrom ) ),
                                'log_timestamp < ' . $dbw->addQuotes( $dbw->timestamp( $this->cutoffTo ) ),
-                               // Some logs don't go in RC since they are private.
-                               // @FIXME: core/extensions also have spammy logs that don't go in RC.
-                               'log_type' => array_diff( LogPage::validTypes(), array_keys( $wgLogRestrictions ) ),
+                               // Some logs don't go in RC since they are private, or are included in the filterable log types.
+                               'log_type' => array_diff( LogPage::validTypes(), $nonRCLogs ),
                        ],
                        __METHOD__,
                        [ 'ORDER BY' => 'log_timestamp DESC' ],
index 1fc0f37..ef9e46e 100644 (file)
@@ -1,7 +1,4 @@
 <?php
-
-use MediaWiki\MediaWikiServices;
-
 /**
  * Maintenance script to wrap all old-style passwords in a layered type
  *
@@ -23,8 +20,11 @@ use MediaWiki\MediaWikiServices;
  * @file
  * @ingroup Maintenance
  */
+
 require_once __DIR__ . '/Maintenance.php';
 
+use MediaWiki\MediaWikiServices;
+
 /**
  * Maintenance script to wrap all passwords of a certain type in a specified layered
  * type that wraps around the old type.
@@ -43,8 +43,7 @@ class WrapOldPasswords extends Maintenance {
        }
 
        public function execute() {
-               $passwordFactory = new PasswordFactory();
-               $passwordFactory->init( RequestContext::getMain()->getConfig() );
+               $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
 
                $typeInfo = $passwordFactory->getTypes();
                $layeredType = $this->getOption( 'type' );
index 26e4e9f..51fb86d 100644 (file)
@@ -410,7 +410,7 @@ class ParserTestRunner {
                // any live Language object, both on setup and teardown
                $reset = function () {
                        MWNamespace::clearCaches();
-                       $GLOBALS['wgContLang']->resetNamespaces();
+                       MediaWikiServices::getInstance()->getContentLanguage()->resetNamespaces();
                };
                $setup[] = $reset;
                $teardown[] = $reset;
@@ -1151,6 +1151,18 @@ class ParserTestRunner {
                $lang = Language::factory( $langCode );
                $lang->resetNamespaces();
                $setup['wgContLang'] = $lang;
+               $setup[] = function () use ( $lang ) {
+                       MediaWikiServices::getInstance()->disableService( 'ContentLanguage' );
+                       MediaWikiServices::getInstance()->redefineService(
+                               'ContentLanguage',
+                               function () use ( $lang ) {
+                                       return $lang;
+                               }
+                       );
+               };
+               $teardown[] = function () {
+                       MediaWikiServices::getInstance()->resetServiceForTesting( 'ContentLanguage' );
+               };
                $reset = function () {
                        MediaWikiServices::getInstance()->resetServiceForTesting( 'MagicWordFactory' );
                        $this->resetTitleServices();
@@ -1610,15 +1622,25 @@ class ParserTestRunner {
         * @param array $articles Article info array from TestFileReader
         */
        public function addArticles( $articles ) {
-               global $wgContLang;
                $setup = [];
                $teardown = [];
 
                // Be sure ParserTestRunner::addArticle has correct language set,
                // so that system messages get into the right language cache
-               if ( $wgContLang->getCode() !== 'en' ) {
+               if ( MediaWikiServices::getInstance()->getContentLanguage()->getCode() !== 'en' ) {
                        $setup['wgLanguageCode'] = 'en';
-                       $setup['wgContLang'] = Language::factory( 'en' );
+                       $lang = Language::factory( 'en' );
+                       $setup['wgContLang'] = $lang;
+                       $setup[] = function () use ( $lang ) {
+                               $services = MediaWikiServices::getInstance();
+                               $services->disableService( 'ContentLanguage' );
+                               $services->redefineService( 'ContentLanguage', function () use ( $lang ) {
+                                       return $lang;
+                               } );
+                       };
+                       $teardown[] = function () {
+                               MediaWikiServices::getInstance()->resetServiceForTesting( 'ContentLanguage' );
+                       };
                }
 
                // Add special namespaces, in case that hasn't been done by staticSetup() yet
index 382bf50..366806e 100644 (file)
@@ -97,6 +97,12 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
         */
        private $mwGlobalsToUnset = [];
 
+       /**
+        * Holds original contents of interwiki table
+        * @var IResultWrapper
+        */
+       private $interwikiTable = null;
+
        /**
         * Holds original loggers which have been replaced by setLogger()
         * @var LoggerInterface[]
@@ -563,6 +569,12 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                        }
                }
 
+               // Store contents of interwiki table in case it changes.  Unfortunately, we seem to have no
+               // way to do this only when needed, because tablesUsed can be changed mid-test.
+               if ( $this->db ) {
+                       $this->interwikiTable = $this->db->select( 'interwiki', '*', '', __METHOD__ );
+               }
+
                // Reset all caches between tests.
                $this->doLightweightServiceReset();
 
@@ -616,6 +628,12 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                foreach ( $this->mwGlobalsToUnset as $value ) {
                        unset( $GLOBALS[$value] );
                }
+               if (
+                       array_key_exists( 'wgExtraNamespaces', $this->mwGlobals ) ||
+                       in_array( 'wgExtraNamespaces', $this->mwGlobalsToUnset )
+               ) {
+                       $this->resetNamespaces();
+               }
                $this->mwGlobals = [];
                $this->mwGlobalsToUnset = [];
                $this->restoreLoggers();
@@ -686,6 +704,10 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                                return $object;
                        }
                );
+
+               if ( $name === 'ContentLanguage' ) {
+                       $this->doSetMwGlobals( [ 'wgContLang' => $object ] );
+               }
        }
 
        /**
@@ -728,11 +750,45 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                        $pairs = [ $pairs => $value ];
                }
 
+               if ( isset( $pairs['wgContLang'] ) ) {
+                       throw new MWException(
+                               'No setting $wgContLang, use setContentLang() or setService( \'ContentLanguage\' )'
+                       );
+               }
+
+               $this->doSetMwGlobals( $pairs, $value );
+       }
+
+       /**
+        * An internal method that allows setService() to set globals that tests are not supposed to
+        * touch.
+        */
+       private function doSetMwGlobals( $pairs, $value = null ) {
                $this->stashMwGlobals( array_keys( $pairs ) );
 
                foreach ( $pairs as $key => $value ) {
                        $GLOBALS[$key] = $value;
                }
+
+               if ( array_key_exists( 'wgExtraNamespaces', $pairs ) ) {
+                       $this->resetNamespaces();
+               }
+       }
+
+       /**
+        * Must be called whenever namespaces are changed, e.g., $wgExtraNamespaces is altered.
+        * Otherwise old namespace data will lurk and cause bugs.
+        */
+       private function resetNamespaces() {
+               MWNamespace::clearCaches();
+               Language::clearCaches();
+
+               // We can't have the TitleFormatter holding on to an old Language object either
+               // @todo We shouldn't need to reset all the aliases here.
+               $services = MediaWikiServices::getInstance();
+               $services->resetServiceForTesting( 'TitleFormatter' );
+               $services->resetServiceForTesting( 'TitleParser' );
+               $services->resetServiceForTesting( '_MediaWikiTitleCodec' );
        }
 
        /**
@@ -946,10 +1002,8 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                        $langCode = $lang;
                        $langObj = Language::factory( $langCode );
                }
-               $this->setMwGlobals( [
-                       'wgLanguageCode' => $langCode,
-                       'wgContLang' => $langObj,
-               ] );
+               $this->setMwGlobals( 'wgLanguageCode', $langCode );
+               $this->setService( 'ContentLanguage', $langObj );
        }
 
        /**
@@ -1707,11 +1761,6 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
 
                        $truncate = in_array( $db->getType(), [ 'oracle', 'mysql' ] );
                        foreach ( $tablesUsed as $tbl ) {
-                               // TODO: reset interwiki table to its original content.
-                               if ( $tbl == 'interwiki' ) {
-                                       continue;
-                               }
-
                                if ( !$db->tableExists( $tbl ) ) {
                                        continue;
                                }
@@ -1727,6 +1776,19 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                                        $db->resetSequenceForTable( $tbl, __METHOD__ );
                                }
 
+                               if ( $tbl === 'interwiki' ) {
+                                       if ( !$this->interwikiTable ) {
+                                               // @todo We should probably throw here, but this causes test failures that I
+                                               // can't figure out, so for now we silently continue.
+                                               continue;
+                                       }
+                                       $db->insert(
+                                               'interwiki',
+                                               array_map( 'get_object_vars', iterator_to_array( $this->interwikiTable ) ),
+                                               __METHOD__
+                                       );
+                               }
+
                                if ( $tbl === 'page' ) {
                                        // Forget about the pages since they don't
                                        // exist in the DB.
index 036b618..216d92c 100644 (file)
 class EditPageTest extends MediaWikiLangTestCase {
 
        protected function setUp() {
-               global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang;
-
                parent::setUp();
 
-               $this->setContentLang( $wgContLang );
-
                $this->setMwGlobals( [
-                       'wgExtraNamespaces' => $wgExtraNamespaces,
-                       'wgNamespaceContentModels' => $wgNamespaceContentModels,
-                       'wgContentHandlers' => $wgContentHandlers,
+                       'wgExtraNamespaces' => [
+                               12312 => 'Dummy',
+                               12313 => 'Dummy_talk',
+                       ],
+                       'wgNamespaceContentModels' => [ 12312 => 'testing' ],
                ] );
-
-               $wgExtraNamespaces[12312] = 'Dummy';
-               $wgExtraNamespaces[12313] = 'Dummy_talk';
-
-               $wgNamespaceContentModels[12312] = "testing";
-               $wgContentHandlers["testing"] = 'DummyContentHandlerForTesting';
-
-               MWNamespace::clearCaches();
-               $wgContLang->resetNamespaces(); # reset namespace cache
-       }
-
-       protected function tearDown() {
-               global $wgContLang;
-
-               MWNamespace::clearCaches();
-               $wgContLang->resetNamespaces(); # reset namespace cache
-               parent::tearDown();
+               $this->mergeMwGlobalArrayValue(
+                       'wgContentHandlers',
+                       [ 'testing' => 'DummyContentHandlerForTesting' ]
+               );
        }
 
        /**
index 4189e93..ae71d9f 100644 (file)
@@ -366,6 +366,8 @@ class MediaWikiServicesTest extends MediaWikiTestCase {
                        'ActorMigration' => [ 'ActorMigration', ActorMigration::class ],
                        'ConfigRepository' => [ 'ConfigRepository', \MediaWiki\Config\ConfigRepository::class ],
                        'MagicWordFactory' => [ 'MagicWordFactory', MagicWordFactory::class ],
+                       'ContentLanguage' => [ 'ContentLanguage', Language::class ],
+                       'PasswordFactory' => [ 'PasswordFactory', PasswordFactory::class ],
                ];
        }
 
index 70f4af9..3e3d04a 100644 (file)
@@ -26,7 +26,7 @@ class MessageTest extends MediaWikiLangTestCase {
 
                $this->assertSame( $key, $message->getKey() );
                $this->assertSame( $params, $message->getParams() );
-               $this->assertEquals( $expectedLang, $message->getLanguage() );
+               $this->assertSame( $expectedLang->getCode(), $message->getLanguage()->getCode() );
 
                $messageSpecifier = $this->getMockForAbstractClass( MessageSpecifier::class );
                $messageSpecifier->expects( $this->any() )
@@ -37,7 +37,7 @@ class MessageTest extends MediaWikiLangTestCase {
 
                $this->assertSame( $key, $message->getKey() );
                $this->assertSame( $params, $message->getParams() );
-               $this->assertEquals( $expectedLang, $message->getLanguage() );
+               $this->assertSame( $expectedLang->getCode(), $message->getLanguage()->getCode() );
        }
 
        public static function provideConstructor() {
index efd61a7..b0cefc7 100644 (file)
@@ -241,6 +241,35 @@ class OutputPageTest extends MediaWikiTestCase {
                        '' . $op->headElement( $op->getContext()->getSkin() ) );
        }
 
+       /**
+        * @covers OutputPage::getHeadItemsArray
+        * @covers OutputPage::addParserOutputMetadata
+        */
+       public function testHeadItemsParserOutput() {
+               $op = $this->newInstance();
+               $stubPO1 = $this->createParserOutputStub( 'getHeadItems', [ 'a' => 'b' ] );
+               $op->addParserOutputMetadata( $stubPO1 );
+               $stubPO2 = $this->createParserOutputStub( 'getHeadItems',
+                       [ 'c' => '<d>&amp;', 'e' => 'f', 'a' => 'q' ] );
+               $op->addParserOutputMetadata( $stubPO2 );
+               $stubPO3 = $this->createParserOutputStub( 'getHeadItems', [ 'e' => 'g' ] );
+               $op->addParserOutputMetadata( $stubPO3 );
+               $stubPO4 = $this->createParserOutputStub( 'getHeadItems', [ 'x' ] );
+               $op->addParserOutputMetadata( $stubPO4 );
+
+               $this->assertSame( [ 'a' => 'q', 'c' => '<d>&amp;', 'e' => 'g', 'x' ],
+                       $op->getHeadItemsArray() );
+
+               $this->assertTrue( $op->hasHeadItem( 'a' ) );
+               $this->assertTrue( $op->hasHeadItem( 'c' ) );
+               $this->assertTrue( $op->hasHeadItem( 'e' ) );
+               $this->assertTrue( $op->hasHeadItem( '0' ) );
+               $this->assertFalse( $op->hasHeadItem( 'b' ) );
+
+               $this->assertContains( "\nq\n<d>&amp;\ng\nx\n",
+                       '' . $op->headElement( $op->getContext()->getSkin() ) );
+       }
+
        /**
         * @covers OutputPage::addBodyClasses
         */
@@ -603,7 +632,16 @@ class OutputPageTest extends MediaWikiTestCase {
         *
         * @covers OutputPage::buildBacklinkSubtitle
         */
-       public function testBuildBacklinkSubtitle( Title $title, $query, $contains, $notContains ) {
+       public function testBuildBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
+               if ( count( $titles ) > 1 ) {
+                       // Not applicable
+                       $this->assertTrue( true );
+                       return;
+               }
+
+               $title = Title::newFromText( $titles[0] );
+               $query = $queries[0];
+
                $this->editPage( 'Page 1', '' );
                $this->editPage( 'Page 2', '#REDIRECT [[Page 1]]' );
 
@@ -624,12 +662,14 @@ class OutputPageTest extends MediaWikiTestCase {
         * @covers OutputPage::addBacklinkSubtitle
         * @covers OutputPage::getSubtitle
         */
-       public function testAddBacklinkSubtitle( Title $title, $query, $contains, $notContains ) {
+       public function testAddBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
                $this->editPage( 'Page 1', '' );
                $this->editPage( 'Page 2', '#REDIRECT [[Page 1]]' );
 
                $op = $this->newInstance();
-               $op->addBacklinkSubtitle( $title, $query );
+               foreach ( $titles as $i => $unused ) {
+                       $op->addBacklinkSubtitle( Title::newFromText( $titles[$i] ), $queries[$i] );
+               }
 
                $str = $op->getSubtitle();
 
@@ -643,48 +683,813 @@ class OutputPageTest extends MediaWikiTestCase {
        }
 
        public function provideBacklinkSubtitle() {
-               $page1 = Title::newFromText( 'Page 1' );
-               $page2 = Title::newFromText( 'Page 2' );
-
                return [
-                       [ $page1, [], [ 'Page 1' ], [ 'redirect', 'Page 2' ] ],
-                       [ $page2, [], [ 'redirect=no' ], [ 'Page 1' ] ],
-                       [ $page1, [ 'action' => 'edit' ], [ 'action=edit' ], [] ],
+                       [
+                               [ 'Page 1' ],
+                               [ [] ],
+                               [ 'Page 1' ],
+                               [ 'redirect', 'Page 2' ],
+                       ],
+                       [
+                               [ 'Page 2' ],
+                               [ [] ],
+                               [ 'redirect=no' ],
+                               [ 'Page 1' ],
+                       ],
+                       [
+                               [ 'Page 1' ],
+                               [ [ 'action' => 'edit' ] ],
+                               [ 'action=edit' ],
+                               [],
+                       ],
+                       [
+                               [ 'Page 1', 'Page 2' ],
+                               [ [], [] ],
+                               [ 'Page 1', 'Page 2', "<br />\n\t\t\t\t" ],
+                               [],
+                       ],
                        // @todo Anything else to test?
                ];
        }
 
        /**
+        * @covers OutputPage::setPrintable
+        * @covers OutputPage::isPrintable
+        */
+       public function testPrintable() {
+               $op = $this->newInstance();
+
+               $this->assertFalse( $op->isPrintable() );
+
+               $op->setPrintable();
+
+               $this->assertTrue( $op->isPrintable() );
+       }
+
+       /**
+        * @covers OutputPage::disable
+        * @covers OutputPage::isDisabled
+        */
+       public function testDisable() {
+               $op = $this->newInstance();
+
+               $this->assertFalse( $op->isDisabled() );
+               $this->assertNotSame( '', $op->output( true ) );
+
+               $op->disable();
+
+               $this->assertTrue( $op->isDisabled() );
+               $this->assertSame( '', $op->output( true ) );
+       }
+
+       /**
+        * @covers OutputPage::showNewSectionLink
+        * @covers OutputPage::addParserOutputMetadata
+        */
+       public function testShowNewSectionLink() {
+               $op = $this->newInstance();
+
+               $this->assertFalse( $op->showNewSectionLink() );
+
+               $po = new ParserOutput();
+               $po->setNewSection( true );
+               $op->addParserOutputMetadata( $po );
+
+               $this->assertTrue( $op->showNewSectionLink() );
+       }
+
+       /**
+        * @covers OutputPage::forceHideNewSectionLink
+        * @covers OutputPage::addParserOutputMetadata
+        */
+       public function testForceHideNewSectionLink() {
+               $op = $this->newInstance();
+
+               $this->assertFalse( $op->forceHideNewSectionLink() );
+
+               $po = new ParserOutput();
+               $po->hideNewSection( true );
+               $op->addParserOutputMetadata( $po );
+
+               $this->assertTrue( $op->forceHideNewSectionLink() );
+       }
+
+       /**
+        * @covers OutputPage::setSyndicated
+        * @covers OutputPage::isSyndicated
+        */
+       public function testSetSyndicated() {
+               $op = $this->newInstance();
+               $this->assertFalse( $op->isSyndicated() );
+
+               $op->setSyndicated();
+               $this->assertTrue( $op->isSyndicated() );
+
+               $op->setSyndicated( false );
+               $this->assertFalse( $op->isSyndicated() );
+       }
+
+       /**
+        * @covers OutputPage::isSyndicated
+        * @covers OutputPage::setFeedAppendQuery
+        * @covers OutputPage::addFeedLink
+        * @covers OutputPage::getSyndicationLinks()
+        */
+       public function testFeedLinks() {
+               $op = $this->newInstance();
+               $this->assertSame( [], $op->getSyndicationLinks() );
+
+               $op->addFeedLink( 'not a supported format', 'abc' );
+               $this->assertFalse( $op->isSyndicated() );
+               $this->assertSame( [], $op->getSyndicationLinks() );
+
+               $feedTypes = $op->getConfig()->get( 'AdvertisedFeedTypes' );
+
+               $op->addFeedLink( $feedTypes[0], 'def' );
+               $this->assertTrue( $op->isSyndicated() );
+               $this->assertSame( [ $feedTypes[0] => 'def' ], $op->getSyndicationLinks() );
+
+               $op->setFeedAppendQuery( false );
+               $expected = [];
+               foreach ( $feedTypes as $type ) {
+                       $expected[$type] = $op->getTitle()->getLocalURL( "feed=$type" );
+               }
+               $this->assertSame( $expected, $op->getSyndicationLinks() );
+
+               $op->setFeedAppendQuery( 'apples=oranges' );
+               foreach ( $feedTypes as $type ) {
+                       $expected[$type] = $op->getTitle()->getLocalURL( "feed=$type&apples=oranges" );
+               }
+               $this->assertSame( $expected, $op->getSyndicationLinks() );
+       }
+
+       /**
+        * @covers OutputPage::setArticleFlag
+        * @covers OutputPage::isArticle
+        * @covers OutputPage::setArticleRelated
+        * @covers OutputPage::isArticleRelated
+        */
+       function testArticleFlags() {
+               $op = $this->newInstance();
+               $this->assertFalse( $op->isArticle() );
+               $this->assertTrue( $op->isArticleRelated() );
+
+               $op->setArticleRelated( false );
+               $this->assertFalse( $op->isArticle() );
+               $this->assertFalse( $op->isArticleRelated() );
+
+               $op->setArticleFlag( true );
+               $this->assertTrue( $op->isArticle() );
+               $this->assertTrue( $op->isArticleRelated() );
+
+               $op->setArticleFlag( false );
+               $this->assertFalse( $op->isArticle() );
+               $this->assertTrue( $op->isArticleRelated() );
+
+               $op->setArticleFlag( true );
+               $op->setArticleRelated( false );
+               $this->assertFalse( $op->isArticle() );
+               $this->assertFalse( $op->isArticleRelated() );
+       }
+
+       /**
+        * @covers OutputPage::addLanguageLinks
+        * @covers OutputPage::setLanguageLinks
+        * @covers OutputPage::getLanguageLinks
+        * @covers OutputPage::addParserOutputMetadata
+        */
+       function testLanguageLinks() {
+               $op = $this->newInstance();
+               $this->assertSame( [], $op->getLanguageLinks() );
+
+               $op->addLanguageLinks( [ 'fr:A', 'it:B' ] );
+               $this->assertSame( [ 'fr:A', 'it:B' ], $op->getLanguageLinks() );
+
+               $op->addLanguageLinks( [ 'de:C', 'es:D' ] );
+               $this->assertSame( [ 'fr:A', 'it:B', 'de:C', 'es:D' ], $op->getLanguageLinks() );
+
+               $op->setLanguageLinks( [ 'pt:E' ] );
+               $this->assertSame( [ 'pt:E' ], $op->getLanguageLinks() );
+
+               $po = new ParserOutput();
+               $po->setLanguageLinks( [ 'he:F', 'ar:G' ] );
+               $op->addParserOutputMetadata( $po );
+               $this->assertSame( [ 'pt:E', 'he:F', 'ar:G' ], $op->getLanguageLinks() );
+       }
+
+       // @todo Are these category links tests too abstract and complicated for what they test?  Would
+       // it make sense to just write out all the tests by hand with maybe some copy-and-paste?
+
+       /**
+        * @dataProvider provideGetCategories
+        *
         * @covers OutputPage::addCategoryLinks
         * @covers OutputPage::getCategories
+        * @covers OutputPage::getCategoryLinks
+        *
+        * @param array $args Array of form [ category name => sort key ]
+        * @param array $fakeResults Array of form [ category name => value to return from mocked
+        *   LinkBatch ]
+        * @param callback $variantLinkCallback Callback to replace findVariantLink() call
+        * @param array $expectedNormal Expected return value of getCategoryLinks['normal']
+        * @param array $expectedHidden Expected return value of getCategoryLinks['hidden']
         */
-       public function testGetCategories() {
-               $fakeResultWrapper = new FakeResultWrapper( [
-                       (object)[
-                               'pp_value' => 1,
-                               'page_title' => 'Test'
-                       ],
-                       (object)[
-                               'page_title' => 'Test2'
-                       ]
-               ] );
+       public function testAddCategoryLinks(
+               array $args, array $fakeResults, callable $variantLinkCallback = null,
+               array $expectedNormal, array $expectedHidden
+       ) {
+               $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'add' );
+               $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'add' );
+
+               $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
+
+               $op->addCategoryLinks( $args );
+
+               $this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
+               $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
+       }
+
+       /**
+        * @dataProvider provideGetCategories
+        *
+        * @covers OutputPage::addCategoryLinks
+        * @covers OutputPage::getCategories
+        * @covers OutputPage::getCategoryLinks
+        */
+       public function testAddCategoryLinksOneByOne(
+               array $args, array $fakeResults, callable $variantLinkCallback = null,
+               array $expectedNormal, array $expectedHidden
+       ) {
+               if ( count( $args ) <= 1 ) {
+                       // @todo Should this be skipped instead of passed?
+                       $this->assertTrue( true );
+                       return;
+               }
+
+               $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'onebyone' );
+               $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'onebyone' );
+
+               $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
+
+               foreach ( $args as $key => $val ) {
+                       $op->addCategoryLinks( [ $key => $val ] );
+               }
+
+               $this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
+               $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
+       }
+
+       /**
+        * @dataProvider provideGetCategories
+        *
+        * @covers OutputPage::setCategoryLinks
+        * @covers OutputPage::getCategories
+        * @covers OutputPage::getCategoryLinks
+        */
+       public function testSetCategoryLinks(
+               array $args, array $fakeResults, callable $variantLinkCallback = null,
+               array $expectedNormal, array $expectedHidden
+       ) {
+               $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'set' );
+               $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'set' );
+
+               $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
+
+               $op->setCategoryLinks( [ 'Initial page' => 'Initial page' ] );
+               $op->setCategoryLinks( $args );
+
+               // We don't reset the categories, for some reason, only the links
+               $expectedNormalCats = array_merge( [ 'Initial page' ], $expectedNormal );
+               $expectedCats = array_merge( $expectedHidden, $expectedNormalCats );
+
+               $this->doCategoryAsserts( $op, $expectedNormalCats, $expectedHidden );
+               $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
+       }
+
+       /**
+        * @dataProvider provideGetCategories
+        *
+        * @covers OutputPage::addParserOutputMetadata
+        * @covers OutputPage::getCategories
+        * @covers OutputPage::getCategoryLinks
+        */
+       public function testParserOutputCategoryLinks(
+               array $args, array $fakeResults, callable $variantLinkCallback = null,
+               array $expectedNormal, array $expectedHidden
+       ) {
+               $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'pout' );
+               $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'pout' );
+
+               $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
+
+               $stubPO = $this->createParserOutputStub( 'getCategories', $args );
+
+               $op->addParserOutputMetadata( $stubPO );
+
+               $this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
+               $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
+       }
+
+       /**
+        * We allow different expectations for different tests as an associative array, like
+        * [ 'set' => [ ... ], 'default' => [ ... ] ] if setCategoryLinks() will give a different
+        * result.
+        */
+       private function extractExpectedCategories( array $expected, $key ) {
+               if ( !$expected || isset( $expected[0] ) ) {
+                       return $expected;
+               }
+               return $expected[$key] ?? $expected['default'];
+       }
+
+       private function setupCategoryTests(
+               array $fakeResults, callable $variantLinkCallback = null
+       ) : OutputPage {
+               $this->setMwGlobals( 'wgUsePigLatinVariant', true );
+
                $op = $this->getMockBuilder( OutputPage::class )
                        ->setConstructorArgs( [ new RequestContext() ] )
                        ->setMethods( [ 'addCategoryLinksToLBAndGetResult' ] )
                        ->getMock();
+
                $op->expects( $this->any() )
                        ->method( 'addCategoryLinksToLBAndGetResult' )
-                       ->will( $this->returnValue( $fakeResultWrapper ) );
+                       ->will( $this->returnCallback( function ( array $categories ) use ( $fakeResults ) {
+                               $return = [];
+                               foreach ( $categories as $category => $unused ) {
+                                       if ( isset( $fakeResults[$category] ) ) {
+                                               $return[] = $fakeResults[$category];
+                                       }
+                               }
+                               return new FakeResultWrapper( $return );
+                       } ) );
+
+               if ( $variantLinkCallback ) {
+                       $mockContLang = $this->getMockBuilder( Language::class )
+                               ->setConstructorArgs( [ 'en' ] )
+                               ->setMethods( [ 'findVariantLink' ] )
+                               ->getMock();
+                       $mockContLang->expects( $this->any() )
+                               ->method( 'findVariantLink' )
+                               ->will( $this->returnCallback( $variantLinkCallback ) );
+                       $this->setContentLang( $mockContLang );
+               }
 
-               $op->addCategoryLinks( [
-                       'Test' => 'Test',
-                       'Test2' => 'Test2',
+               $this->assertSame( [], $op->getCategories() );
+
+               return $op;
+       }
+
+       private function doCategoryAsserts( $op, $expectedNormal, $expectedHidden ) {
+               $this->assertSame( array_merge( $expectedHidden, $expectedNormal ), $op->getCategories() );
+               $this->assertSame( $expectedNormal, $op->getCategories( 'normal' ) );
+               $this->assertSame( $expectedHidden, $op->getCategories( 'hidden' ) );
+       }
+
+       private function doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden ) {
+               $catLinks = $op->getCategoryLinks();
+               $this->assertSame( (bool)$expectedNormal + (bool)$expectedHidden, count( $catLinks ) );
+               if ( $expectedNormal ) {
+                       $this->assertSame( count( $expectedNormal ), count( $catLinks['normal'] ) );
+               }
+               if ( $expectedHidden ) {
+                       $this->assertSame( count( $expectedHidden ), count( $catLinks['hidden'] ) );
+               }
+
+               foreach ( $expectedNormal as $i => $name ) {
+                       $this->assertContains( $name, $catLinks['normal'][$i] );
+               }
+               foreach ( $expectedHidden as $i => $name ) {
+                       $this->assertContains( $name, $catLinks['hidden'][$i] );
+               }
+       }
+
+       public function provideGetCategories() {
+               return [
+                       'No categories' => [ [], [], null, [], [] ],
+                       'Simple test' => [
+                               [ 'Test1' => 'Some sortkey', 'Test2' => 'A different sortkey' ],
+                               [ 'Test1' => (object)[ 'pp_value' => 1, 'page_title' => 'Test1' ],
+                                       'Test2' => (object)[ 'page_title' => 'Test2' ] ],
+                               null,
+                               [ 'Test2' ],
+                               [ 'Test1' ],
+                       ],
+                       'Invalid title' => [
+                               [ '[' => '[', 'Test' => 'Test' ],
+                               [ 'Test' => (object)[ 'page_title' => 'Test' ] ],
+                               null,
+                               [ 'Test' ],
+                               [],
+                       ],
+                       'Variant link' => [
+                               [ 'Test' => 'Test', 'Estay' => 'Estay' ],
+                               [ 'Test' => (object)[ 'page_title' => 'Test' ] ],
+                               function ( &$link, &$title ) {
+                                       if ( $link === 'Estay' ) {
+                                               $link = 'Test';
+                                               $title = Title::makeTitleSafe( NS_CATEGORY, $link );
+                                       }
+                               },
+                               // For adding one by one, the variant gets added as well as the original category,
+                               // but if you add them all together the second time gets skipped.
+                               [ 'onebyone' => [ 'Test', 'Test' ], 'default' => [ 'Test' ] ],
+                               [],
+                       ],
+               ];
+       }
+
+       /**
+        * @covers OutputPage::getCategories
+        */
+       public function testGetCategoriesInvalid() {
+               $this->setExpectedException( InvalidArgumentException::class,
+                       'Invalid category type given: hiddne' );
+
+               $op = $this->newInstance();
+               $op->getCategories( 'hiddne' );
+       }
+
+       // @todo Should we test addCategoryLinksToLBAndGetResult?  If so, how?  Insert some test rows in
+       // the DB?
+
+       /**
+        * @covers OutputPage::setIndicators
+        * @covers OutputPage::getIndicators
+        * @covers OutputPage::addParserOutputMetadata
+        */
+       public function testIndicators() {
+               $op = $this->newInstance();
+               $this->assertSame( [], $op->getIndicators() );
+
+               $op->setIndicators( [] );
+               $this->assertSame( [], $op->getIndicators() );
+
+               // Test sorting alphabetically
+               $op->setIndicators( [ 'b' => 'x', 'a' => 'y' ] );
+               $this->assertSame( [ 'a' => 'y', 'b' => 'x' ], $op->getIndicators() );
+
+               // Test overwriting existing keys
+               $op->setIndicators( [ 'c' => 'z', 'a' => 'w' ] );
+               $this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'z' ], $op->getIndicators() );
+
+               // Test with ParserOutput
+               $stubPO = $this->createParserOutputStub( 'getIndicators', [ 'c' => 'u', 'd' => 'v' ] );
+               $op->addParserOutputMetadata( $stubPO );
+               $this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'u', 'd' => 'v' ],
+                       $op->getIndicators() );
+       }
+
+       /**
+        * @covers OutputPage::addHelpLink
+        * @covers OutputPage::getIndicators
+        */
+       public function testAddHelpLink() {
+               $op = $this->newInstance();
+
+               $op->addHelpLink( 'Manual:PHP unit testing' );
+               $indicators = $op->getIndicators();
+               $this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
+               $this->assertContains( 'Manual:PHP_unit_testing', $indicators['mw-helplink'] );
+
+               $op->addHelpLink( 'https://phpunit.de', true );
+               $indicators = $op->getIndicators();
+               $this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
+               $this->assertContains( 'https://phpunit.de', $indicators['mw-helplink'] );
+               $this->assertNotContains( 'mediawiki', $indicators['mw-helplink'] );
+               $this->assertNotContains( 'Manual:PHP', $indicators['mw-helplink'] );
+       }
+
+       /**
+        * @covers OutputPage::prependHTML
+        * @covers OutputPage::addHTML
+        * @covers OutputPage::addElement
+        * @covers OutputPage::clearHTML
+        * @covers OutputPage::getHTML
+        */
+       public function testBodyHTML() {
+               $op = $this->newInstance();
+               $this->assertSame( '', $op->getHTML() );
+
+               $op->addHTML( 'a' );
+               $this->assertSame( 'a', $op->getHTML() );
+
+               $op->addHTML( 'b' );
+               $this->assertSame( 'ab', $op->getHTML() );
+
+               $op->prependHTML( 'c' );
+               $this->assertSame( 'cab', $op->getHTML() );
+
+               $op->addElement( 'p', [ 'id' => 'foo' ], 'd' );
+               $this->assertSame( 'cab<p id="foo">d</p>', $op->getHTML() );
+
+               $op->clearHTML();
+               $this->assertSame( '', $op->getHTML() );
+       }
+
+       /**
+        * @dataProvider provideRevisionId
+        * @covers OutputPage::setRevisionId
+        * @covers OutputPage::getRevisionId
+        */
+       public function testRevisionId( $newVal, $expected ) {
+               $op = $this->newInstance();
+
+               $this->assertNull( $op->setRevisionId( $newVal ) );
+               $this->assertSame( $expected, $op->getRevisionId() );
+               $this->assertSame( $expected, $op->setRevisionId( null ) );
+               $this->assertNull( $op->getRevisionId() );
+       }
+
+       public function provideRevisionId() {
+               return [
+                       [ null, null ],
+                       [ 7, 7 ],
+                       [ -1, -1 ],
+                       [ 3.2, 3 ],
+                       [ '0', 0 ],
+                       [ '32% finished', 32 ],
+                       [ false, 0 ],
+               ];
+       }
+
+       /**
+        * @covers OutputPage::setRevisionTimestamp
+        * @covers OutputPage::getRevisionTimestamp
+        */
+       public function testRevisionTimestamp() {
+               $op = $this->newInstance();
+               $this->assertNull( $op->getRevisionTimestamp() );
+
+               $this->assertNull( $op->setRevisionTimestamp( 'abc' ) );
+               $this->assertSame( 'abc', $op->getRevisionTimestamp() );
+               $this->assertSame( 'abc', $op->setRevisionTimestamp( null ) );
+               $this->assertNull( $op->getRevisionTimestamp() );
+       }
+
+       /**
+        * @covers OutputPage::setFileVersion
+        * @covers OutputPage::getFileVersion
+        */
+       public function testFileVersion() {
+               $op = $this->newInstance();
+               $this->assertNull( $op->getFileVersion() );
+
+               $stubFile = $this->createMock( File::class );
+               $stubFile->method( 'exists' )->willReturn( true );
+               $stubFile->method( 'getTimestamp' )->willReturn( '12211221123321' );
+               $stubFile->method( 'getSha1' )->willReturn( 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' );
+
+               $op->setFileVersion( $stubFile );
+
+               $this->assertEquals(
+                       [ 'time' => '12211221123321', 'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' ],
+                       $op->getFileVersion()
+               );
+
+               $stubMissingFile = $this->createMock( File::class );
+               $stubMissingFile->method( 'exists' )->willReturn( false );
+
+               $op->setFileVersion( $stubMissingFile );
+               $this->assertNull( $op->getFileVersion() );
+
+               $op->setFileVersion( $stubFile );
+               $this->assertNotNull( $op->getFileVersion() );
+
+               $op->setFileVersion( null );
+               $this->assertNull( $op->getFileVersion() );
+       }
+
+       private function createParserOutputStub( $method = '', $retVal = [] ) {
+               $pOut = $this->getMock( ParserOutput::class );
+               if ( $method !== '' ) {
+                       $pOut->method( $method )->willReturn( $retVal );
+               }
+
+               $arrayReturningMethods = [
+                       'getCategories',
+                       'getFileSearchOptions',
+                       'getHeadItems',
+                       'getIndicators',
+                       'getLanguageLinks',
+                       'getOutputHooks',
+                       'getTemplateIds',
+               ];
+
+               foreach ( $arrayReturningMethods as $method ) {
+                       $pOut->method( $method )->willReturn( [] );
+               }
+
+               return $pOut;
+       }
+
+       /**
+        * @covers OutputPage::getTemplateIds
+        * @covers OutputPage::addParserOutputMetadata
+        */
+       public function testTemplateIds() {
+               $op = $this->newInstance();
+               $this->assertSame( [], $op->getTemplateIds() );
+
+               // Test with no template id's
+               $stubPOEmpty = $this->createParserOutputStub();
+               $op->addParserOutputMetadata( $stubPOEmpty );
+               $this->assertSame( [], $op->getTemplateIds() );
+
+               // Test with some arbitrary template id's
+               $ids = [
+                       NS_MAIN => [ 'A' => 3, 'B' => 17 ],
+                       NS_TALK => [ 'C' => 31 ],
+                       NS_MEDIA => [ 'D' => -1 ],
+               ];
+
+               $stubPO1 = $this->createParserOutputStub( 'getTemplateIds', $ids );
+
+               $op->addParserOutputMetadata( $stubPO1 );
+               $this->assertSame( $ids, $op->getTemplateIds() );
+
+               // Test merging with a second set of id's
+               $stubPO2 = $this->createParserOutputStub( 'getTemplateIds', [
+                       NS_MAIN => [ 'E' => 1234 ],
+                       NS_PROJECT => [ 'F' => 5678 ],
                ] );
-               $this->assertEquals( [ 0 => 'Test', '1' => 'Test2' ], $op->getCategories() );
-               $this->assertEquals( [ 0 => 'Test2' ], $op->getCategories( 'normal' ) );
-               $this->assertEquals( [ 0 => 'Test' ], $op->getCategories( 'hidden' ) );
+
+               $finalIds = [
+                       NS_MAIN => [ 'E' => 1234, 'A' => 3, 'B' => 17 ],
+                       NS_TALK => [ 'C' => 31 ],
+                       NS_MEDIA => [ 'D' => -1 ],
+                       NS_PROJECT => [ 'F' => 5678 ],
+               ];
+
+               $op->addParserOutputMetadata( $stubPO2 );
+               $this->assertSame( $finalIds, $op->getTemplateIds() );
+
+               // Test merging with an empty set of id's
+               $op->addParserOutputMetadata( $stubPOEmpty );
+               $this->assertSame( $finalIds, $op->getTemplateIds() );
+       }
+
+       /**
+        * @covers OutputPage::getFileSearchOptions
+        * @covers OutputPage::addParserOutputMetadata
+        */
+       public function testFileSearchOptions() {
+               $op = $this->newInstance();
+               $this->assertSame( [], $op->getFileSearchOptions() );
+
+               // Test with no files
+               $stubPOEmpty = $this->createParserOutputStub();
+
+               $op->addParserOutputMetadata( $stubPOEmpty );
+               $this->assertSame( [], $op->getFileSearchOptions() );
+
+               // Test with some arbitrary files
+               $files1 = [
+                       'A' => [ 'time' => null, 'sha1' => '' ],
+                       'B' => [
+                               'time' => '12211221123321',
+                               'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05',
+                       ],
+               ];
+
+               $stubPO1 = $this->createParserOutputStub( 'getFileSearchOptions', $files1 );
+
+               $op->addParserOutputMetadata( $stubPO1 );
+               $this->assertSame( $files1, $op->getFileSearchOptions() );
+
+               // Test merging with a second set of files
+               $files2 = [
+                       'C' => [ 'time' => null, 'sha1' => '' ],
+                       'B' => [ 'time' => null, 'sha1' => '' ],
+               ];
+
+               $stubPO2 = $this->createParserOutputStub( 'getFileSearchOptions', $files2 );
+
+               $op->addParserOutputMetadata( $stubPO2 );
+               $this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() );
+
+               // Test merging with an empty set of files
+               $op->addParserOutputMetadata( $stubPOEmpty );
+               $this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() );
        }
 
+       /**
+        * @dataProvider provideAddWikiText
+        * @covers OutputPage::addWikiText
+        * @covers OutputPage::addWikiTextWithTitle
+        * @covers OutputPage::addWikiTextTitle
+        * @covers OutputPage::getHTML
+        */
+       public function testAddWikiText( $method, array $args, $expected ) {
+               $op = $this->newInstance();
+               $this->assertSame( '', $op->getHTML() );
+
+               if ( in_array(
+                       $method,
+                       [ 'addWikiTextWithTitle', 'addWikiTextTitleTidy', 'addWikiTextTitle' ]
+               ) && count( $args ) >= 2 && $args[1] === null ) {
+                       // Special placeholder because we can't get the actual title in the provider
+                       $args[1] = $op->getTitle();
+               }
+
+               $op->$method( ...$args );
+               $this->assertSame( $expected, $op->getHTML() );
+       }
+
+       public function provideAddWikiText() {
+               $tests = [
+                       'addWikiText' => [
+                               'Simple wikitext' => [
+                                       [ "'''Bold'''" ],
+                                       "<p><b>Bold</b>\n</p>",
+                               ], 'List at start' => [
+                                       [ '* List' ],
+                                       "<ul><li>List</li></ul>\n",
+                               ], 'List not at start' => [
+                                       [ '* Not a list', false ],
+                                       '* Not a list',
+                               ], 'Non-interface' => [
+                                       [ "'''Bold'''", true, false ],
+                                       "<div class=\"mw-parser-output\"><p><b>Bold</b>\n</p></div>",
+                               ], 'No section edit links' => [
+                                       [ '== Title ==' ],
+                                       "<h2><span class=\"mw-headline\" id=\"Title\">Title</span></h2>\n",
+                               ],
+                       ],
+                       'addWikiTextWithTitle' => [
+                               'With title at start' => [
+                                       [ '* {{PAGENAME}}', Title::newFromText( 'Talk:Some page' ) ],
+                                       "<div class=\"mw-parser-output\"><ul><li>Some page</li></ul>\n</div>",
+                               ], 'With title at start' => [
+                                       [ '* {{PAGENAME}}', Title::newFromText( 'Talk:Some page' ), false ],
+                                       "<div class=\"mw-parser-output\">* Some page</div>",
+                               ],
+                       ],
+               ];
+
+               // Test all the others on addWikiTextTitle as well
+               foreach ( $tests['addWikiText'] as $key => $val ) {
+                       $args = [ $val[0][0], null, $val[0][1] ?? true, false, $val[0][2] ?? true ];
+                       $tests['addWikiTextTitle']["$key (addWikiTextTitle)"] =
+                               array_merge( [ $args ], array_slice( $val, 1 ) );
+               }
+               foreach ( $tests['addWikiTextWithTitle'] as $key => $val ) {
+                       $args = [ $val[0][0], $val[0][1], $val[0][2] ?? true ];
+                       $tests['addWikiTextTitle']["$key (addWikiTextTitle)"] =
+                               array_merge( [ $args ], array_slice( $val, 1 ) );
+               }
+
+               // We have to reformat our array to match what PHPUnit wants
+               $ret = [];
+               foreach ( $tests as $key => $subarray ) {
+                       foreach ( $subarray as $subkey => $val ) {
+                               $val = array_merge( [ $key ], $val );
+                               $ret[$subkey] = $val;
+                       }
+               }
+
+               return $ret;
+       }
+
+       /**
+        * @covers OutputPage::addWikiText
+        */
+       public function testAddWikiTextNoTitle() {
+               $this->setExpectedException( MWException::class, 'Title is null' );
+
+               $op = $this->newInstance( [], null, 'notitle' );
+               $op->addWikiText( 'a' );
+       }
+
+       // @todo How should we cover the Tidy variants?
+
+       /**
+        * @covers OutputPage::addParserOutputMetadata
+        */
+       public function testNoGallery() {
+               $op = $this->newInstance();
+               $this->assertFalse( $op->mNoGallery );
+
+               $stubPO1 = $this->createParserOutputStub( 'getNoGallery', true );
+               $op->addParserOutputMetadata( $stubPO1 );
+               $this->assertTrue( $op->mNoGallery );
+
+               $stubPO2 = $this->createParserOutputStub( 'getNoGallery', false );
+               $op->addParserOutputMetadata( $stubPO2 );
+               $this->assertFalse( $op->mNoGallery );
+       }
+
+       // @todo Make sure to test the following in addParserOutputMetadata() as well when we add tests
+       // for them:
+       //   * enableClientCache()
+       //   * addModules()
+       //   * addModuleScripts()
+       //   * addModuleStyles()
+       //   * addJsConfigVars()
+       //   * preventClickJacking()
+       // Otherwise those lines of addParserOutputMetadata() will be reported as covered, but we won't
+       // be testing they actually work.
+
        /**
         * @covers OutputPage::haveCacheVaryCookies
         */
@@ -1311,7 +2116,7 @@ class OutputPageTest extends MediaWikiTestCase {
        /**
         * @return OutputPage
         */
-       private function newInstance( $config = [], WebRequest $request = null ) {
+       private function newInstance( $config = [], WebRequest $request = null, $options = [] ) {
                $context = new RequestContext();
 
                $context->setConfig( new MultiConfig( [
@@ -1330,7 +2135,9 @@ class OutputPageTest extends MediaWikiTestCase {
                        $context->getConfig()
                ] ) );
 
-               $context->setTitle( Title::newFromText( 'My test page' ) );
+               if ( !in_array( 'notitle', (array)$options ) ) {
+                       $context->setTitle( Title::newFromText( 'My test page' ) );
+               }
 
                if ( $request ) {
                        $context->setRequest( $request );
index f602cda..646b487 100644 (file)
@@ -27,18 +27,20 @@ class PagePropsTest extends MediaWikiLangTestCase {
        private $the_properties;
 
        protected function setUp() {
-               global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang;
-
                parent::setUp();
 
-               $wgExtraNamespaces[12312] = 'Dummy';
-               $wgExtraNamespaces[12313] = 'Dummy_talk';
-
-               $wgNamespaceContentModels[12312] = 'DUMMY';
-               $wgContentHandlers['DUMMY'] = 'DummyContentHandlerForTesting';
+               $this->setMwGlobals( [
+                       'wgExtraNamespaces' => [
+                               12312 => 'Dummy',
+                               12313 => 'Dummy_talk',
+                       ],
+                       'wgNamespaceContentModels' => [ 12312 => 'DUMMY' ],
+               ] );
 
-               MWNamespace::clearCaches();
-               $wgContLang->resetNamespaces(); # reset namespace cache
+               $this->mergeMwGlobalArrayValue(
+                       'wgContentHandlers',
+                       [ 'DUMMY' => 'DummyContentHandlerForTesting' ]
+               );
 
                if ( !$this->the_properties ) {
                        $this->the_properties = [
@@ -72,21 +74,6 @@ class PagePropsTest extends MediaWikiLangTestCase {
                }
        }
 
-       protected function tearDown() {
-               global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang;
-
-               parent::tearDown();
-
-               unset( $wgExtraNamespaces[12312] );
-               unset( $wgExtraNamespaces[12313] );
-
-               unset( $wgNamespaceContentModels[12312] );
-               unset( $wgContentHandlers['DUMMY'] );
-
-               MWNamespace::clearCaches();
-               $wgContLang->resetNamespaces(); # reset namespace cache
-       }
-
        /**
         * Test getting a single property from a single page. The property was
         * set in setUp().
index f12fe1d..5606924 100644 (file)
@@ -50,7 +50,7 @@ class PrefixSearchTest extends MediaWikiLangTestCase {
                        $this->markTestSkipped( 'Main namespace does not support wikitext.' );
                }
 
-               // Avoid special pages from extensions interferring with the tests
+               // Avoid special pages from extensions interfering with the tests
                $this->setMwGlobals( [
                        'wgSpecialPages' => [],
                        'wgHooks' => [],
@@ -61,17 +61,10 @@ class PrefixSearchTest extends MediaWikiLangTestCase {
                $this->originalHandlers = TestingAccessWrapper::newFromClass( Hooks::class )->handlers;
                TestingAccessWrapper::newFromClass( Hooks::class )->handlers = [];
 
-               // Clear caches so that our new namespace appears
-               MWNamespace::clearCaches();
-               Language::factory( 'en' )->resetNamespaces();
-
                SpecialPageFactory::resetList();
        }
 
        public function tearDown() {
-               MWNamespace::clearCaches();
-               Language::factory( 'en' )->resetNamespaces();
-
                parent::tearDown();
 
                TestingAccessWrapper::newFromClass( Hooks::class )->handlers = $this->originalHandlers;
index e17f855..57b42f6 100644 (file)
@@ -58,8 +58,6 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase {
        abstract protected function getMcrTablesToReset();
 
        protected function setUp() {
-               global $wgContLang;
-
                $this->tablesUsed += $this->getMcrTablesToReset();
 
                parent::setUp();
@@ -93,10 +91,6 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase {
                        $this->getMcrMigrationStage()
                );
 
-               MWNamespace::clearCaches();
-               // Reset namespace cache
-               $wgContLang->resetNamespaces();
-
                $this->overrideMwServices();
 
                if ( !$this->testPage ) {
@@ -108,16 +102,6 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase {
                }
        }
 
-       protected function tearDown() {
-               global $wgContLang;
-
-               parent::tearDown();
-
-               MWNamespace::clearCaches();
-               // Reset namespace cache
-               $wgContLang->resetNamespaces();
-       }
-
        abstract protected function getContentHandlerUseDB();
 
        private function makeRevisionWithProps( $props = null ) {
index 86f4ae7..952a662 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use MediaWiki\MediaWikiServices;
+
 /**
  * Wraps the user object, so we can also retain full access to properties
  * like password if we log in via the API.
@@ -140,8 +142,7 @@ class TestUser {
                        throw new MWException( "Passed User has an ID but is not in the database?" );
                }
 
-               $passwordFactory = new PasswordFactory();
-               $passwordFactory->init( RequestContext::getMain()->getConfig() );
+               $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
                if ( !$passwordFactory->newFromCiphertext( $row->user_password )->equals( $password ) ) {
                        $passwordHash = $passwordFactory->newFromPlaintext( $password );
                        $dbw->update(
index e898c63..715d469 100644 (file)
@@ -12,8 +12,6 @@ use MediaWiki\MediaWikiServices;
 class TitleMethodsTest extends MediaWikiLangTestCase {
 
        protected function setUp() {
-               global $wgContLang;
-
                parent::setUp();
 
                $this->mergeMwGlobalArrayValue(
@@ -30,18 +28,6 @@ class TitleMethodsTest extends MediaWikiLangTestCase {
                                12302 => CONTENT_MODEL_JAVASCRIPT,
                        ]
                );
-
-               MWNamespace::clearCaches();
-               $wgContLang->resetNamespaces(); # reset namespace cache
-       }
-
-       protected function tearDown() {
-               global $wgContLang;
-
-               parent::tearDown();
-
-               MWNamespace::clearCaches();
-               $wgContLang->resetNamespaces(); # reset namespace cache
        }
 
        public static function provideEquals() {
index 4febb46..4c276d6 100644 (file)
 class ApiEditPageTest extends ApiTestCase {
 
        protected function setUp() {
-               global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang;
-
                parent::setUp();
 
-               $this->setContentLang( $wgContLang );
-
                $this->setMwGlobals( [
-                       'wgExtraNamespaces' => $wgExtraNamespaces,
-                       'wgNamespaceContentModels' => $wgNamespaceContentModels,
-                       'wgContentHandlers' => $wgContentHandlers,
+                       'wgExtraNamespaces' => [
+                               12312 => 'Dummy',
+                               12313 => 'Dummy_talk',
+                               12314 => 'DummyNonText',
+                               12315 => 'DummyNonText_talk',
+                       ],
+                       'wgNamespaceContentModels' => [
+                               12312 => 'testing',
+                               12314 => 'testing-nontext',
+                       ],
+               ] );
+               $this->mergeMwGlobalArrayValue( 'wgContentHandlers', [
+                       'testing' => 'DummyContentHandlerForTesting',
+                       'testing-nontext' => 'DummyNonTextContentHandler',
+                       'testing-serialize-error' => 'DummySerializeErrorContentHandler',
                ] );
-
-               $wgExtraNamespaces[12312] = 'Dummy';
-               $wgExtraNamespaces[12313] = 'Dummy_talk';
-               $wgExtraNamespaces[12314] = 'DummyNonText';
-               $wgExtraNamespaces[12315] = 'DummyNonText_talk';
-
-               $wgNamespaceContentModels[12312] = "testing";
-               $wgNamespaceContentModels[12314] = "testing-nontext";
-
-               $wgContentHandlers["testing"] = 'DummyContentHandlerForTesting';
-               $wgContentHandlers["testing-nontext"] = 'DummyNonTextContentHandler';
-               $wgContentHandlers["testing-serialize-error"] =
-                       'DummySerializeErrorContentHandler';
-
-               MWNamespace::clearCaches();
-               $wgContLang->resetNamespaces(); # reset namespace cache
-       }
-
-       protected function tearDown() {
-               global $wgContLang;
-
-               MWNamespace::clearCaches();
-               $wgContLang->resetNamespaces(); # reset namespace cache
-
-               parent::tearDown();
        }
 
        public function testEdit() {
index d382c83..384d779 100644 (file)
@@ -1,5 +1,6 @@
 <?php
 
+use MediaWiki\MediaWikiServices;
 use Wikimedia\TestingAccessWrapper;
 
 /**
@@ -233,8 +234,7 @@ class ApiLoginTest extends ApiTestCase {
                $this->assertNotEquals( 0, $centralId, 'sanity check' );
 
                $password = 'ngfhmjm64hv0854493hsj5nncjud2clk';
-               $passwordFactory = new PasswordFactory();
-               $passwordFactory->init( RequestContext::getMain()->getConfig() );
+               $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
                // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
                $passwordHash = $passwordFactory->newFromPlaintext( $password );
 
diff --git a/tests/phpunit/includes/api/ApiQuerySiteinfoTest.php b/tests/phpunit/includes/api/ApiQuerySiteinfoTest.php
new file mode 100644 (file)
index 0000000..7b93571
--- /dev/null
@@ -0,0 +1,665 @@
+<?php
+
+/**
+ * @group API
+ * @group medium
+ *
+ * @covers ApiQuerySiteinfo
+ */
+class ApiQuerySiteinfoTest extends ApiTestCase {
+       // We don't try to test every single thing for every category, just a sample
+
+       protected function doQuery( $siprop = null, $extraParams = [] ) {
+               $params = [ 'action' => 'query', 'meta' => 'siteinfo' ];
+               if ( $siprop !== null ) {
+                       $params['siprop'] = $siprop;
+               }
+               $params = array_merge( $params, $extraParams );
+
+               $res = $this->doApiRequest( $params );
+
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+               $this->assertCount( 1, $res[0]['query'] );
+
+               return $res[0]['query'][$siprop === null ? 'general' : $siprop];
+       }
+
+       public function testGeneral() {
+               $this->setMwGlobals( [
+                       'wgAllowExternalImagesFrom' => '//localhost/',
+               ] );
+
+               $data = $this->doQuery();
+
+               $this->assertSame( Title::newMainPage()->getPrefixedText(), $data['mainpage'] );
+               $this->assertSame( PHP_VERSION, $data['phpversion'] );
+               $this->assertSame( [ '//localhost/' ], $data['externalimages'] );
+       }
+
+       public function testLinkPrefixCharset() {
+               global $wgContLang;
+
+               $this->setContentLang( 'ar' );
+               $this->assertTrue( $wgContLang->linkPrefixExtension(), 'Sanity check' );
+
+               $data = $this->doQuery();
+
+               $this->assertSame( $wgContLang->linkPrefixCharset(), $data['linkprefixcharset'] );
+       }
+
+       public function testVariants() {
+               global $wgContLang;
+
+               $this->setContentLang( 'zh' );
+               $this->assertTrue( $wgContLang->hasVariants(), 'Sanity check' );
+
+               $data = $this->doQuery();
+
+               $expected = array_map(
+                       function ( $code ) use ( $wgContLang ) {
+                               return [ 'code' => $code, 'name' => $wgContLang->getVariantname( $code ) ];
+                       },
+                       $wgContLang->getVariants()
+               );
+
+               $this->assertSame( $expected, $data['variants'] );
+       }
+
+       public function testReadOnly() {
+               $svc = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode();
+               $svc->setReason( 'Need more donations' );
+               try {
+                       $data = $this->doQuery();
+               } finally {
+                       $svc->setReason( false );
+               }
+
+               $this->assertTrue( $data['readonly'] );
+               $this->assertSame( 'Need more donations', $data['readonlyreason'] );
+       }
+
+       public function testNamespaces() {
+               global $wgContLang;
+
+               $this->setMwGlobals( 'wgExtraNamespaces', [ '138' => 'Testing' ] );
+
+               $this->assertSame( array_keys( $wgContLang->getFormattedNamespaces() ),
+                       array_keys( $this->doQuery( 'namespaces' ) ) );
+       }
+
+       public function testNamespaceAliases() {
+               global $wgNamespaceAliases, $wgContLang;
+
+               $expected = array_merge( $wgNamespaceAliases, $wgContLang->getNamespaceAliases() );
+               $expected = array_map(
+                       function ( $key, $val ) {
+                               return [ 'id' => $val, 'alias' => strtr( $key, '_', ' ' ) ];
+                       },
+                       array_keys( $expected ),
+                       $expected
+               );
+
+               // Test that we don't list duplicates
+               $this->mergeMwGlobalArrayValue( 'wgNamespaceAliases', [ 'Talk' => NS_TALK ] );
+
+               $this->assertSame( $expected, $this->doQuery( 'namespacealiases' ) );
+       }
+
+       public function testSpecialPageAliases() {
+               $this->assertCount(
+                       count( SpecialPageFactory::getNames() ),
+                       $this->doQuery( 'specialpagealiases' )
+               );
+       }
+
+       public function testMagicWords() {
+               global $wgContLang;
+
+               $this->assertCount(
+                       count( $wgContLang->getMagicWords() ),
+                       $this->doQuery( 'magicwords' )
+               );
+       }
+
+       /**
+        * @dataProvider interwikiMapProvider
+        */
+       public function testInterwikiMap( $filter ) {
+               global $wgServer, $wgScriptPath;
+
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->insert(
+                       'interwiki',
+                       [
+                               [
+                                       'iw_prefix' => 'self',
+                                       'iw_url' => "$wgServer$wgScriptPath/index.php?title=$1",
+                                       'iw_api' => "$wgServer$wgScriptPath/api.php",
+                                       'iw_wikiid' => 'somedbname',
+                                       'iw_local' => true,
+                                       'iw_trans' => true,
+                               ],
+                               [
+                                       'iw_prefix' => 'foreign',
+                                       'iw_url' => '//foreign.example/wiki/$1',
+                                       'iw_api' => '',
+                                       'iw_wikiid' => '',
+                                       'iw_local' => false,
+                                       'iw_trans' => false,
+                               ],
+                       ],
+                       __METHOD__,
+                       'IGNORE'
+               );
+               $this->tablesUsed[] = 'interwiki';
+
+               $this->setMwGlobals( [
+                       'wgLocalInterwikis' => [ 'self' ],
+                       'wgExtraInterlanguageLinkPrefixes' => [ 'self' ],
+                       'wgExtraLanguageNames' => [ 'self' => 'Recursion' ],
+               ] );
+
+               MessageCache::singleton()->enable();
+
+               $this->editPage( 'MediaWiki:Interlanguage-link-self', 'Self!' );
+               $this->editPage( 'MediaWiki:Interlanguage-link-sitename-self', 'Circular logic' );
+
+               $expected = [];
+
+               if ( $filter === null || $filter === '!local' ) {
+                       $expected[] = [
+                               'prefix' => 'foreign',
+                               'url' => wfExpandUrl( '//foreign.example/wiki/$1', PROTO_CURRENT ),
+                               'protorel' => true,
+                       ];
+               }
+               if ( $filter === null || $filter === 'local' ) {
+                       $expected[] = [
+                               'prefix' => 'self',
+                               'local' => true,
+                               'trans' => true,
+                               'language' => 'Recursion',
+                               'localinterwiki' => true,
+                               'extralanglink' => true,
+                               'linktext' => 'Self!',
+                               'sitename' => 'Circular logic',
+                               'url' => "$wgServer$wgScriptPath/index.php?title=$1",
+                               'protorel' => false,
+                               'wikiid' => 'somedbname',
+                               'api' => "$wgServer$wgScriptPath/api.php",
+                       ];
+               }
+
+               $data = $this->doQuery( 'interwikimap',
+                       $filter === null ? [] : [ 'sifilteriw' => $filter ] );
+
+               $this->assertSame( $expected, $data );
+       }
+
+       public function interwikiMapProvider() {
+               return [ [ 'local' ], [ '!local' ], [ null ] ];
+       }
+
+       /**
+        * @dataProvider dbReplLagProvider
+        */
+       public function testDbReplLagInfo( $showHostnames, $includeAll ) {
+               if ( !$showHostnames && $includeAll ) {
+                       $this->setExpectedApiException( 'apierror-siteinfo-includealldenied' );
+               }
+
+               $mockLB = $this->getMockBuilder( LoadBalancer::class )
+                       ->disableOriginalConstructor()
+                       ->setMethods( [ 'getMaxLag', 'getLagTimes', 'getServerName', '__destruct' ] )
+                       ->getMock();
+               $mockLB->method( 'getMaxLag' )->willReturn( [ null, 7, 1 ] );
+               $mockLB->method( 'getLagTimes' )->willReturn( [ 5, 7 ] );
+               $mockLB->method( 'getServerName' )->will( $this->returnValueMap( [
+                       [ 0, 'apple' ], [ 1, 'carrot' ]
+               ] ) );
+               $this->setService( 'DBLoadBalancer', $mockLB );
+
+               $this->setMwGlobals( 'wgShowHostnames', $showHostnames );
+
+               $expected = [];
+               if ( $includeAll ) {
+                       $expected[] = [ 'host' => $showHostnames ? 'apple' : '', 'lag' => 5 ];
+               }
+               $expected[] = [ 'host' => $showHostnames ? 'carrot' : '', 'lag' => 7 ];
+
+               $data = $this->doQuery( 'dbrepllag', $includeAll ? [ 'sishowalldb' => '' ] : [] );
+
+               $this->assertSame( $expected, $data );
+       }
+
+       public function dbReplLagProvider() {
+               return [
+                       'no hostnames, no showalldb' => [ false, false ],
+                       'no hostnames, showalldb' => [ false, true ],
+                       'hostnames, no showalldb' => [ true, false ],
+                       'hostnames, showalldb' => [ true, true ]
+               ];
+       }
+
+       public function testStatistics() {
+               $this->setTemporaryHook( 'APIQuerySiteInfoStatisticsInfo',
+                       function ( &$data ) {
+                               $data['addedstats'] = 42;
+                       }
+               );
+
+               $expected = [
+                       'pages' => intval( SiteStats::pages() ),
+                       'articles' => intval( SiteStats::articles() ),
+                       'edits' => intval( SiteStats::edits() ),
+                       'images' => intval( SiteStats::images() ),
+                       'users' => intval( SiteStats::users() ),
+                       'activeusers' => intval( SiteStats::activeUsers() ),
+                       'admins' => intval( SiteStats::numberingroup( 'sysop' ) ),
+                       'jobs' => intval( SiteStats::jobs() ),
+                       'addedstats' => 42,
+               ];
+
+               $this->assertSame( $expected, $this->doQuery( 'statistics' ) );
+       }
+
+       /**
+        * @dataProvider groupsProvider
+        */
+       public function testUserGroups( $numInGroup ) {
+               global $wgGroupPermissions, $wgAutopromote;
+
+               $this->setGroupPermissions( 'viscount', 'perambulate', 'yes' );
+               $this->setGroupPermissions( 'viscount', 'legislate', '0' );
+               $this->setMwGlobals( [
+                       'wgAddGroups' => [ 'viscount' => true, 'bot' => [] ],
+                       'wgRemoveGroups' => [ 'viscount' => [ 'sysop' ], 'bot' => [ '*', 'earl' ] ],
+                       'wgGroupsAddToSelf' => [ 'bot' => [ 'bureaucrat', 'sysop' ] ],
+                       'wgGroupsRemoveFromSelf' => [ 'bot' => [ 'bot' ] ],
+               ] );
+
+               $data = $this->doQuery( 'usergroups', $numInGroup ? [ 'sinumberingroup' => '' ] : [] );
+
+               $names = array_map(
+                       function ( $val ) {
+                               return $val['name'];
+                       },
+                       $data
+               );
+
+               $this->assertSame( array_keys( $wgGroupPermissions ), $names );
+
+               foreach ( $data as $val ) {
+                       if ( !$numInGroup ) {
+                               $expectedSize = null;
+                       } elseif ( $val['name'] === 'user' ) {
+                               $expectedSize = SiteStats::users();
+                       } elseif ( $val['name'] === '*' || isset( $wgAutopromote[$val['name']] ) ) {
+                               $expectedSize = null;
+                       } else {
+                               $expectedSize = SiteStats::numberingroup( $val['name'] );
+                       }
+
+                       if ( $expectedSize === null ) {
+                               $this->assertArrayNotHasKey( 'number', $val );
+                       } else {
+                               $this->assertSame( $expectedSize, $val['number'] );
+                       }
+
+                       if ( $val['name'] === 'viscount' ) {
+                               $viscountFound = true;
+                               $this->assertSame( [ 'perambulate' ], $val['rights'] );
+                               $this->assertSame( User::getAllGroups(), $val['add'] );
+                       } elseif ( $val['name'] === 'bot' ) {
+                               $this->assertArrayNotHasKey( 'add', $val );
+                               $this->assertArrayNotHasKey( 'remove', $val );
+                               $this->assertSame( [ 'bureaucrat', 'sysop' ], $val['add-self'] );
+                               $this->assertSame( [ 'bot' ], $val['remove-self'] );
+                       }
+               }
+       }
+
+       public function testFileExtensions() {
+               global $wgFileExtensions;
+
+               $this->stashMwGlobals( 'wgFileExtensions' );
+               // Add duplicate
+               $wgFileExtensions[] = 'png';
+
+               $expected = array_map(
+                       function ( $val ) {
+                               return [ 'ext' => $val ];
+                       },
+                       array_unique( $wgFileExtensions )
+               );
+
+               $this->assertSame( $expected, $this->doQuery( 'fileextensions' ) );
+       }
+
+       public function groupsProvider() {
+               return [
+                       'numingroup' => [ true ],
+                       'nonumingroup' => [ false ],
+               ];
+       }
+
+       public function testInstalledLibraries() {
+               // @todo Test no installed.json?  Moving installed.json to a different name temporarily
+               // seems a bit scary, but I don't see any other way to do it.
+               //
+               // @todo Install extensions/skins somehow so that we can test they're filtered out
+               global $IP;
+
+               $path = "$IP/vendor/composer/installed.json";
+               if ( !file_exists( $path ) ) {
+                       $this->markTestSkipped( 'No installed libraries' );
+               }
+
+               $expected = ( new ComposerInstalled( $path ) )->getInstalledDependencies();
+
+               $expected = array_filter( $expected,
+                       function ( $info ) {
+                               return strpos( $info['type'], 'mediawiki-' ) !== 0;
+                       }
+               );
+
+               $expected = array_map(
+                       function ( $name, $info ) {
+                               return [ 'name' => $name, 'version' => $info['version'] ];
+                       },
+                       array_keys( $expected ),
+                       array_values( $expected )
+               );
+
+               $this->assertSame( $expected, $this->doQuery( 'libraries' ) );
+       }
+
+       public function testExtensions() {
+               $tmpdir = $this->getNewTempDirectory();
+               touch( "$tmpdir/ErsatzExtension.php" );
+               touch( "$tmpdir/LICENSE" );
+               touch( "$tmpdir/AUTHORS.txt" );
+
+               $val = [
+                       'path' => "$tmpdir/ErsatzExtension.php",
+                       'name' => 'Ersatz Extension',
+                       'namemsg' => 'ersatz-extension-name',
+                       'author' => 'John Smith',
+                       'version' => '0.0.2',
+                       'url' => 'https://www.example.com/software/ersatz-extension',
+                       'description' => 'An extension that is not what it seems.',
+                       'descriptionmsg' => 'ersatz-extension-desc',
+                       'license-name' => 'PD',
+               ];
+
+               $this->setMwGlobals( 'wgExtensionCredits', [ 'api' => [
+                       $val,
+                       [
+                               'author' => [ 'John Smith', 'John Smith Jr.', '...' ],
+                               'descriptionmsg' => [ 'another-extension-desc', 'param' ] ],
+               ] ] );
+
+               $data = $this->doQuery( 'extensions' );
+
+               $this->assertCount( 2, $data );
+
+               $this->assertSame( 'api', $data[0]['type'] );
+
+               $sharedKeys = [ 'name', 'namemsg', 'description', 'descriptionmsg', 'author', 'url',
+                       'version', 'license-name' ];
+               foreach ( $sharedKeys as $key ) {
+                       $this->assertSame( $val[$key], $data[0][$key] );
+               }
+
+               // @todo Test git info
+
+               $this->assertSame(
+                       Title::newFromText( 'Special:Version/License/Ersatz Extension' )->getLinkURL(),
+                       $data[0]['license']
+               );
+
+               $this->assertSame(
+                       Title::newFromText( 'Special:Version/Credits/Ersatz Extension' )->getLinkURL(),
+                       $data[0]['credits']
+               );
+
+               $this->assertSame( 'another-extension-desc', $data[1]['descriptionmsg'] );
+               $this->assertSame( [ 'param' ], $data[1]['descriptionmsgparams'] );
+               $this->assertSame( 'John Smith, John Smith Jr., ...', $data[1]['author'] );
+       }
+
+       /**
+        * @dataProvider rightsInfoProvider
+        */
+       public function testRightsInfo( $page, $url, $text, $expectedUrl, $expectedText ) {
+               $this->setMwGlobals( [
+                       'wgRightsPage' => $page,
+                       'wgRightsUrl' => $url,
+                       'wgRightsText' => $text,
+               ] );
+
+               $this->assertSame(
+                       [ 'url' => $expectedUrl, 'text' => $expectedText ],
+                       $this->doQuery( 'rightsinfo' )
+               );
+       }
+
+       public function rightsInfoProvider() {
+               $textUrl = wfExpandUrl( Title::newFromText( 'License' ), PROTO_CURRENT );
+               $url = 'http://license.example/';
+
+               return [
+                       'No rights info' => [ null, null, null, '', '' ],
+                       'Only page' => [ 'License', null, null, $textUrl, 'License' ],
+                       'Only URL' => [ null, $url, null, $url, '' ],
+                       'Only text' => [ null, null, '!!!', '', '!!!' ],
+                       // URL is ignored if page is specified
+                       'Page and URL' => [ 'License', $url, null, $textUrl, 'License' ],
+                       'URL and text' => [ null, $url, '!!!', $url, '!!!' ],
+                       'Page and text' => [ 'License', null, '!!!', $textUrl, '!!!' ],
+                       'Page and URL and text' => [ 'License', $url, '!!!', $textUrl, '!!!' ],
+                       'Pagename "0"' => [ '0', null, null,
+                               wfExpandUrl( Title::newFromText( '0' ), PROTO_CURRENT ), '0' ],
+                       'URL "0"' => [ null, '0', null, '0', '' ],
+                       'Text "0"' => [ null, null, '0', '', '0' ],
+               ];
+       }
+
+       public function testRestrictions() {
+               global $wgRestrictionTypes, $wgRestrictionLevels, $wgCascadingRestrictionLevels,
+                       $wgSemiprotectedRestrictionLevels;
+
+               $this->assertSame( [
+                       'types' => $wgRestrictionTypes,
+                       'levels' => $wgRestrictionLevels,
+                       'cascadinglevels' => $wgCascadingRestrictionLevels,
+                       'semiprotectedlevels' => $wgSemiprotectedRestrictionLevels,
+               ], $this->doQuery( 'restrictions' ) );
+       }
+
+       /**
+        * @dataProvider languagesProvider
+        */
+       public function testLanguages( $langCode ) {
+               $expected = Language::fetchLanguageNames( (string)$langCode );
+
+               $expected = array_map(
+                       function ( $code, $name ) {
+                               return [
+                                       'code' => $code,
+                                       'name' => $name
+                               ];
+                       },
+                       array_keys( $expected ),
+                       array_values( $expected )
+               );
+
+               $data = $this->doQuery( 'languages',
+                       $langCode !== null ? [ 'siinlanguagecode' => $langCode ] : [] );
+
+               $this->assertSame( $expected, $data );
+       }
+
+       public function languagesProvider() {
+               return [ [ null ], [ 'fr' ] ];
+       }
+
+       public function testLanguageVariants() {
+               $expectedKeys = array_filter( LanguageConverter::$languagesWithVariants,
+                       function ( $langCode ) {
+                               return !Language::factory( $langCode )->getConverter() instanceof FakeConverter;
+                       }
+               );
+               sort( $expectedKeys );
+
+               $this->assertSame( $expectedKeys, array_keys( $this->doQuery( 'languagevariants' ) ) );
+       }
+
+       public function testLanguageVariantsDisabled() {
+               $this->setMwGlobals( 'wgDisableLangConversion', true );
+
+               $this->assertSame( [], $this->doQuery( 'languagevariants' ) );
+       }
+
+       /**
+        * @todo Test a skin with a description that's known to be different in a different language.
+        *   Vector will do, but it's not installed by default.
+        *
+        * @todo Test that an invalid language code doesn't actually try reading any messages
+        *
+        * @dataProvider skinsProvider
+        */
+       public function testSkins( $code ) {
+               $data = $this->doQuery( 'skins', $code !== null ? [ 'siinlanguagecode' => $code ] : [] );
+
+               $expectedAllowed = Skin::getAllowedSkins();
+               $expectedDefault = Skin::normalizeKey( 'default' );
+
+               $i = 0;
+               foreach ( Skin::getSkinNames() as $name => $displayName ) {
+                       $this->assertSame( $name, $data[$i]['code'] );
+
+                       $msg = wfMessage( "skinname-$name" );
+                       if ( $code && Language::isValidCode( $code ) ) {
+                               $msg->inLanguage( $code );
+                       } else {
+                               $msg->inContentLanguage();
+                       }
+                       if ( $msg->exists() ) {
+                               $displayName = $msg->text();
+                       }
+                       $this->assertSame( $displayName, $data[$i]['name'] );
+
+                       if ( !isset( $expectedAllowed[$name] ) ) {
+                               $this->assertTrue( $data[$i]['unusable'], "$name must be unusable" );
+                       }
+                       if ( $name === $expectedDefault ) {
+                               $this->assertTrue( $data[$i]['default'], "$expectedDefault must be default" );
+                       }
+                       $i++;
+               }
+       }
+
+       public function skinsProvider() {
+               return [
+                       'No language specified' => [ null ],
+                       'Czech' => [ 'cs' ],
+                       'Invalid language' => [ '/invalid/' ],
+               ];
+       }
+
+       public function testExtensionTags() {
+               global $wgParser;
+
+               $wgParser->firstCallInit();
+               $expected = array_map(
+                       function ( $tag ) {
+                               return "<$tag>";
+                       },
+                       $wgParser->getTags()
+               );
+
+               $this->assertSame( $expected, $this->doQuery( 'extensiontags' ) );
+       }
+
+       public function testFunctionHooks() {
+               global $wgParser;
+
+               $wgParser->firstCallInit();
+               $this->assertSame( $wgParser->getFunctionHooks(), $this->doQuery( 'functionhooks' ) );
+       }
+
+       public function testVariables() {
+               $this->assertSame( MagicWord::getVariableIDs(), $this->doQuery( 'variables' ) );
+       }
+
+       public function testProtocols() {
+               global $wgUrlProtocols;
+
+               $this->assertSame( $wgUrlProtocols, $this->doQuery( 'protocols' ) );
+       }
+
+       public function testDefaultOptions() {
+               $this->assertSame( User::getDefaultOptions(), $this->doQuery( 'defaultoptions' ) );
+       }
+
+       public function testUploadDialog() {
+               global $wgUploadDialog;
+
+               $this->assertSame( $wgUploadDialog, $this->doQuery( 'uploaddialog' ) );
+       }
+
+       public function testGetHooks() {
+               global $wgHooks;
+
+               // Make sure there's something to report on
+               $this->setTemporaryHook( 'somehook',
+                       function () {
+                               return;
+                       }
+               );
+
+               $expectedNames = $wgHooks;
+               ksort( $expectedNames );
+
+               $actualNames = array_map(
+                       function ( $val ) {
+                               return $val['name'];
+                       },
+                       $this->doQuery( 'showhooks' )
+               );
+
+               $this->assertSame( array_keys( $expectedNames ), $actualNames );
+       }
+
+       public function testContinuation() {
+               // We make lots and lots of URL protocols that are each 100 bytes
+               global $wgAPIMaxResultSize, $wgUrlProtocols;
+
+               $this->setMwGlobals( 'wgUrlProtocols', [] );
+
+               // Just under the limit
+               $chunks = $wgAPIMaxResultSize / 100 - 1;
+
+               for ( $i = 0; $i < $chunks; $i++ ) {
+                       $wgUrlProtocols[] = substr( str_repeat( "$i ", 50 ), 0, 100 );
+               }
+
+               $res = $this->doApiRequest( [
+                       'action' => 'query',
+                       'meta' => 'siteinfo',
+                       'siprop' => 'protocols|languages',
+               ] );
+
+               $this->assertSame(
+                       wfMessage( 'apiwarn-truncatedresult', Message::numParam( $wgAPIMaxResultSize ) )
+                               ->text(),
+                       $res[0]['warnings']['result']['warnings']
+               );
+
+               $this->assertSame( $wgUrlProtocols, $res[0]['query']['protocols'] );
+               $this->assertArrayNotHasKey( 'languages', $res[0] );
+               $this->assertTrue( $res[0]['batchcomplete'], 'batchcomplete should be true' );
+               $this->assertSame( [ 'siprop' => 'languages', 'continue' => '-||' ], $res[0]['continue'] );
+       }
+}
index 0fce35a..9a27cf1 100644 (file)
@@ -207,4 +207,17 @@ abstract class ApiTestCase extends MediaWikiLangTestCase {
                        'ApiTestCase::setUp can be slow, tests must be "medium" or "large"'
                );
        }
+
+       /**
+        * Expect an ApiUsageException to be thrown with the given parameters, which are the same as
+        * ApiUsageException::newWithMessage()'s parameters.  This allows checking for an exception
+        * whose text is given by a message key instead of text, so as not to hard-code the message's
+        * text into test code.
+        */
+       protected function setExpectedApiException(
+               $msg, $code = null, array $data = null, $httpCode = 0
+       ) {
+               $expected = ApiUsageException::newWithMessage( null, $msg, $code, $data, $httpCode );
+               $this->setExpectedException( ApiUsageException::class, $expected->getMessage() );
+       }
 }
index d14ad59..7170e55 100644 (file)
@@ -82,6 +82,31 @@ class AuthManagerTest extends \MediaWikiTestCase {
                return new \Message( $key, $params, \Language::factory( 'en' ) );
        }
 
+       /**
+        * Test two AuthenticationResponses for equality.  We don't want to use regular assertEquals
+        * because that recursively compares members, which leads to false negatives if e.g. Language
+        * caches are reset.
+        *
+        * @param AuthenticationResponse $response1
+        * @param AuthenticationResponse $response2
+        * @param string $msg
+        * @return bool
+        */
+       private function assertResponseEquals(
+               AuthenticationResponse $expected, AuthenticationResponse $actual, $msg = ''
+       ) {
+               foreach ( ( new \ReflectionClass( $expected ) )->getProperties() as $prop ) {
+                       $name = $prop->getName();
+                       $usedMsg = ltrim( "$msg ($name)" );
+                       if ( $name === 'message' && $expected->message ) {
+                               $this->assertSame( $expected->message->serialize(), $actual->message->serialize(),
+                                       $usedMsg );
+                       } else {
+                               $this->assertEquals( $expected->$name, $actual->$name, $usedMsg );
+                       }
+               }
+       }
+
        /**
         * Initialize the AuthManagerConfig variable in $this->config
         *
@@ -1030,7 +1055,7 @@ class AuthManagerTest extends \MediaWikiTestCase {
                        $this->assertSame( 'http://localhost/', $req->returnToUrl );
 
                        $ret->message = $this->message( $ret->message );
-                       $this->assertEquals( $response, $ret, "Response $i, response" );
+                       $this->assertResponseEquals( $response, $ret, "Response $i, response" );
                        if ( $success ) {
                                $this->assertSame( $id, $session->getUser()->getId(),
                                        "Response $i, authn" );
@@ -2082,7 +2107,7 @@ class AuthManagerTest extends \MediaWikiTestCase {
                                        "Response $i, login marker" );
                        }
                        $ret->message = $this->message( $ret->message );
-                       $this->assertEquals( $response, $ret, "Response $i, response" );
+                       $this->assertResponseEquals( $response, $ret, "Response $i, response" );
                        if ( $success || $response->status === AuthenticationResponse::FAIL ) {
                                $this->assertNull(
                                        $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ),
@@ -3517,7 +3542,7 @@ class AuthManagerTest extends \MediaWikiTestCase {
                        $this->assertSame( 'http://localhost/', $req->returnToUrl );
 
                        $ret->message = $this->message( $ret->message );
-                       $this->assertEquals( $response, $ret, "Response $i, response" );
+                       $this->assertResponseEquals( $response, $ret, "Response $i, response" );
                        if ( $response->status === AuthenticationResponse::PASS ||
                                $response->status === AuthenticationResponse::FAIL
                        ) {
index 1708f1c..8863aa2 100644 (file)
@@ -128,11 +128,10 @@ class TemporaryPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestC
                $user = self::getMutableTestUser()->getUser();
 
                $dbw = wfGetDB( DB_MASTER );
-
-               $passwordFactory = new \PasswordFactory();
-               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               $config = MediaWikiServices::getInstance()->getMainConfig();
                // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
-               $passwordFactory->setDefaultType( 'A' );
+               $passwordFactory = new \PasswordFactory( $config->get( 'PasswordConfig' ), 'A' );
+
                $pwhash = $passwordFactory->newFromPlaintext( 'password' )->toString();
 
                $provider = $this->getProvider();
index 7c63105..323a63d 100644 (file)
@@ -8,7 +8,6 @@ use MediaWiki\MediaWikiServices;
 class ContentHandlerTest extends MediaWikiTestCase {
 
        protected function setUp() {
-               global $wgContLang;
                parent::setUp();
 
                $this->setMwGlobals( [
@@ -34,20 +33,12 @@ class ContentHandlerTest extends MediaWikiTestCase {
                        ],
                ] );
 
-               // Reset namespace cache
-               MWNamespace::clearCaches();
-               $wgContLang->resetNamespaces();
-               // And LinkCache
+               // Reset LinkCache
                MediaWikiServices::getInstance()->resetServiceForTesting( 'LinkCache' );
        }
 
        protected function tearDown() {
-               global $wgContLang;
-
-               // Reset namespace cache
-               MWNamespace::clearCaches();
-               $wgContLang->resetNamespaces();
-               // And LinkCache
+               // Reset LinkCache
                MediaWikiServices::getInstance()->resetServiceForTesting( 'LinkCache' );
 
                parent::tearDown();
index fff101f..e64d4af 100644 (file)
@@ -190,6 +190,11 @@ class ExifRotationTest extends MediaWikiMediaTestCase {
                        $file = $this->dataFile( $name, $type );
                        $thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE );
 
+                       if ( $thumb->isError() ) {
+                               /** @var MediaTransformError $thumb */
+                               $this->fail( $thumb->toText() );
+                       }
+
                        $this->assertEquals(
                                $out[0],
                                $thumb->getWidth(),
index 01b0de2..a7b3557 100644 (file)
@@ -4,6 +4,20 @@
  * @covers PasswordFactory
  */
 class PasswordFactoryTest extends MediaWikiTestCase {
+       public function testConstruct() {
+               $pf = new PasswordFactory();
+               $this->assertEquals( [ '' ], array_keys( $pf->getTypes() ) );
+               $this->assertEquals( '', $pf->getDefaultType() );
+
+               $pf = new PasswordFactory( [
+                       'foo' => [ 'class' => 'FooPassword' ],
+                       'bar' => [ 'class' => 'BarPassword', 'baz' => 'boom' ],
+               ], 'foo' );
+               $this->assertEquals( [ '', 'foo', 'bar' ], array_keys( $pf->getTypes() ) );
+               $this->assertArraySubset( [ 'class' => 'BarPassword', 'baz' => 'boom' ], $pf->getTypes()['bar'] );
+               $this->assertEquals( 'foo', $pf->getDefaultType() );
+       }
+
        public function testRegister() {
                $pf = new PasswordFactory;
                $pf->register( 'foo', [ 'class' => InvalidPassword::class ] );
index 4767994..2298056 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace MediaWiki\Session;
 
+use MediaWiki\MediaWikiServices;
 use Psr\Log\LogLevel;
 use MediaWikiTestCase;
 use Wikimedia\TestingAccessWrapper;
@@ -63,8 +64,7 @@ class BotPasswordSessionProviderTest extends MediaWikiTestCase {
        }
 
        public function addDBDataOnce() {
-               $passwordFactory = new \PasswordFactory();
-               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
                $passwordHash = $passwordFactory->newFromPlaintext( 'foobaz' );
 
                $sysop = static::getTestSysop()->getUser();
index 3bbc2df..0d22b21 100644 (file)
@@ -1,5 +1,6 @@
 <?php
 
+use MediaWiki\MediaWikiServices;
 use MediaWiki\Session\SessionManager;
 use Wikimedia\ScopedCallback;
 use Wikimedia\TestingAccessWrapper;
@@ -59,8 +60,7 @@ class BotPasswordTest extends MediaWikiTestCase {
        }
 
        public function addDBData() {
-               $passwordFactory = new \PasswordFactory();
-               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
                $passwordHash = $passwordFactory->newFromPlaintext( 'foobaz' );
 
                $dbw = wfGetDB( DB_MASTER );
@@ -350,8 +350,7 @@ class BotPasswordTest extends MediaWikiTestCase {
         * @param string|null $password
         */
        public function testSave( $password ) {
-               $passwordFactory = new \PasswordFactory();
-               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               $passwordFactory = MediaWikiServices::getInstance()->getPasswordFactory();
 
                $bp = BotPassword::newUnsaved( [
                        'centralId' => 42,
index 35bb1f0..f99bc70 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use Wikimedia\TestingAccessWrapper;
+
 class LanguageTest extends LanguageClassesTestCase {
        /**
         * @covers Language::convertDoubleWidth
@@ -1771,6 +1773,45 @@ class LanguageTest extends LanguageClassesTestCase {
                $this->assertEquals( "a{$c}b{$c}c{$and}{$s}d", $lang->listToText( [ 'a', 'b', 'c', 'd' ] ) );
        }
 
+       /**
+        * @covers Language::clearCaches
+        */
+       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 );
+
+               // Populate $fallbackLanguageCache
+               Language::getFallbacksIncludingSiteLanguage( 'en' );
+               $this->assertNotCount( 0, $languageClass->fallbackLanguageCache );
+
+               // Populate $grammarTransformations
+               $lang->getGrammarTransformations();
+               $this->assertNotNull( $languageClass->grammarTransformations );
+
+               // Populate $languageNameCache
+               Language::fetchLanguageNames();
+               $this->assertNotNull( $languageClass->languageNameCache );
+
+               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 );
+               $this->assertNull( $languageClass->languageNameCache );
+       }
+
        /**
         * @dataProvider provideIsSupportedLanguage
         * @covers Language::isSupportedLanguage