Merge "Special:PasswordReset: Make the user field a user lookahead field, not just...
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Tue, 10 Jul 2018 17:24:24 +0000 (17:24 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Tue, 10 Jul 2018 17:24:24 +0000 (17:24 +0000)
21 files changed:
docs/extension.schema.v1.json
docs/extension.schema.v2.json
includes/ContentSecurityPolicy.php
includes/DefaultSettings.php
includes/Html.php
includes/OutputPage.php
includes/cache/HTMLFileCache.php
includes/changes/ChangesList.php
includes/page/PageArchive.php
includes/specials/SpecialWatchlist.php
languages/Language.php
resources/src/mediawiki.inspect.js
resources/src/mediawiki.special.preferences.ooui/tabs.js
resources/src/mediawiki.special.preferences.styles.ooui.less
tests/common/TestsAutoLoader.php
tests/phpunit/includes/ContentSecurityPolicyTest.php
tests/phpunit/includes/PageArchiveTest.php [deleted file]
tests/phpunit/includes/page/PageArchiveMcrTest.php [new file with mode: 0644]
tests/phpunit/includes/page/PageArchivePreMcrTest.php [new file with mode: 0644]
tests/phpunit/includes/page/PageArchiveTestBase.php [new file with mode: 0644]
tests/phpunit/languages/LanguageTest.php

index e7e0975..a8f66c9 100644 (file)
                                                                "selectorWithVariant": {
                                                                        "type": "string"
                                                                },
+                                                               "useDataURI": {
+                                                                       "type": "boolean"
+                                                               },
                                                                "variants": {
                                                                        "type": "object"
                                                                },
index 24bfb63..c9d33e1 100644 (file)
                                                                "selectorWithVariant": {
                                                                        "type": "string"
                                                                },
+                                                               "useDataURI": {
+                                                                       "type": "boolean"
+                                                               },
                                                                "variants": {
                                                                        "type": "object"
                                                                },
index 66a3535..91117f4 100644 (file)
@@ -171,7 +171,6 @@ class ContentSecurityPolicy {
 
                $additionalSelfUrls = $this->getAdditionalSelfUrls();
                $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
-               $nonceSrc = "'nonce-" . $this->nonce . "'";
 
                // If no default-src is sent at all, it
                // seems browsers (or at least some), interpret
@@ -183,7 +182,11 @@ class ContentSecurityPolicy {
                $cssSrc = false;
                $imgSrc = false;
                $scriptSrc = [ "'unsafe-eval'", "'self'" ];
-               if ( $mode !== self::FULL_MODE_RESTRICTED ) {
+               if (
+                       $mode !== self::FULL_MODE_RESTRICTED &&
+                       ( !isset( $policyConfig['useNonces'] ) || $policyConfig['useNonces'] )
+               ) {
+                       $nonceSrc = "'nonce-" . $this->nonce . "'";
                        $scriptSrc[] = $nonceSrc;
                }
                $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
@@ -518,13 +521,28 @@ class ContentSecurityPolicy {
        }
 
        /**
-        * Is CSP currently enabled (i.e. Should we set nonce attribute)
+        * Should we set nonce attribute
         *
         * @param Config $config Configuration object
         * @return bool
         */
-       public static function isEnabled( Config $config ) {
-               return $config->get( 'CSPHeader' ) !== false
-                       || $config->get( 'CSPReportOnlyHeader' ) !== false;
+       public static function isNonceRequired( Config $config ) {
+               $configs = [
+                       $config->get( 'CSPHeader' ),
+                       $config->get( 'CSPReportOnlyHeader' )
+               ];
+               foreach ( $configs as $headerConfig ) {
+                       if (
+                               $headerConfig === true ||
+                               ( is_array( $headerConfig ) &&
+                               !isset( $headerConfig['useNonces'] ) ) ||
+                               ( is_array( $headerConfig ) &&
+                               isset( $headerConfig['useNonces'] ) &&
+                               $headerConfig['useNonces'] )
+                       ) {
+                               return true;
+                       }
+               }
+               return false;
        }
 }
index f3bc9cc..2fa3b72 100644 (file)
@@ -8759,6 +8759,8 @@ $wgMaxJobDBWriteDuration = false;
  *    $wgCrossSiteAJAXdomains as an allowed load sources.
  *  'unsafeFallback' Add unsafe-inline as a script source, as a fallback for
  *    browsers that do not understand nonce-sources [default on].
+ *  'useNonces' Require nonces on all inline scripts. If disabled and 'unsafeFallback'
+ *    is on, then all inline scripts will be allowed [default true].
  *  'script-src' Array of additional places that are allowed to have JS be loaded from.
  *  'report-uri' true to use MW api [default], false to disable, string for alternate uri
  * @warning May cause slowness on windows due to slow random number generator.
index 3dd21c1..ad0130b 100644 (file)
@@ -565,7 +565,7 @@ class Html {
                if ( $nonce !== null ) {
                        $attrs['nonce'] = $nonce;
                } else {
-                       if ( ContentSecurityPolicy::isEnabled( RequestContext::getMain()->getConfig() ) ) {
+                       if ( ContentSecurityPolicy::isNonceRequired( RequestContext::getMain()->getConfig() ) ) {
                                wfWarn( "no nonce set on script. CSP will break it" );
                        }
                }
@@ -590,7 +590,7 @@ class Html {
                if ( $nonce !== null ) {
                        $attrs['nonce'] = $nonce;
                } else {
-                       if ( ContentSecurityPolicy::isEnabled( RequestContext::getMain()->getConfig() ) ) {
+                       if ( ContentSecurityPolicy::isNonceRequired( RequestContext::getMain()->getConfig() ) ) {
                                wfWarn( "no nonce set on script. CSP will break it" );
                        }
                }
index 3b4b14a..948e1eb 100644 (file)
@@ -4011,7 +4011,7 @@ class OutputPage extends ContextSource {
         * @since 1.32
         */
        public function getCSPNonce() {
-               if ( !ContentSecurityPolicy::isEnabled( $this->getConfig() ) ) {
+               if ( !ContentSecurityPolicy::isNonceRequired( $this->getConfig() ) ) {
                        return false;
                }
                if ( $this->CSPNonce === null ) {
index 7ae2ee0..f8cd754 100644 (file)
@@ -91,6 +91,7 @@ class HTMLFileCache extends FileCacheBase {
         * @return bool
         */
        public static function useFileCache( IContextSource $context, $mode = self::MODE_NORMAL ) {
+               global $wgContLang;
                $config = MediaWikiServices::getInstance()->getMainConfig();
 
                if ( !$config->get( 'UseFileCache' ) && $mode !== self::MODE_REBUILD ) {
@@ -123,7 +124,7 @@ class HTMLFileCache extends FileCacheBase {
                $ulang = $context->getLanguage();
 
                // Check that there are no other sources of variation
-               if ( $user->getId() || $ulang->getCode() !== $config->get( 'LanguageCode' ) ) {
+               if ( $user->getId() || !$ulang->equals( $wgContLang ) ) {
                        return false;
                }
 
index facf29e..c0822c3 100644 (file)
@@ -652,7 +652,8 @@ class ChangesList extends ContextSource {
                }
        }
 
-       /** Inserts a rollback link
+       /**
+        * Insert a rollback link
         *
         * @param string &$s
         * @param RecentChange &$rc
@@ -661,15 +662,14 @@ class ChangesList extends ContextSource {
                if ( $rc->mAttribs['rc_type'] == RC_EDIT
                        && $rc->mAttribs['rc_this_oldid']
                        && $rc->mAttribs['rc_cur_id']
+                       && $rc->getAttribute( 'page_latest' ) == $rc->mAttribs['rc_this_oldid']
                ) {
-                       $page = $rc->getTitle();
-                       /** Check for rollback and edit permissions, disallow special pages, and only
+                       $title = $rc->getTitle();
+                       /** Check for rollback permissions, disallow special pages, and only
                         * show a link on the top-most revision */
-                       if ( $this->getUser()->isAllowed( 'rollback' )
-                               && $rc->mAttribs['page_latest'] == $rc->mAttribs['rc_this_oldid']
-                       ) {
+                       if ( $title->quickUserCan( 'rollback', $this->getUser() ) ) {
                                $rev = new Revision( [
-                                       'title' => $page,
+                                       'title' => $title,
                                        'id' => $rc->mAttribs['rc_this_oldid'],
                                        'user' => $rc->mAttribs['rc_user'],
                                        'user_text' => $rc->mAttribs['rc_user_text'],
index 9681ece..fc079e2 100644 (file)
  */
 
 use MediaWiki\MediaWikiServices;
-use Wikimedia\Rdbms\ResultWrapper;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionStore;
+use MediaWiki\Storage\SqlBlobStore;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\IResultWrapper;
 use Wikimedia\Rdbms\IDatabase;
 
 /**
@@ -50,6 +54,17 @@ class PageArchive {
                $this->config = $config;
        }
 
+       /**
+        * @return RevisionStore
+        */
+       private function getRevisionStore() {
+               // TODO: Refactor: delete()/undelete() should live in a PageStore service;
+               //       Methods in PageArchive and RevisionStore that deal with archive revisions
+               //       should move into an ArchiveStore service (but could still be implemented
+               //       together with RevisionStore).
+               return MediaWikiServices::getInstance()->getRevisionStore();
+       }
+
        public function doesWrites() {
                return true;
        }
@@ -59,9 +74,13 @@ class PageArchive {
         * wrapper with (ar_namespace, ar_title, count) fields, ordered by page
         * namespace/title.
         *
-        * @return ResultWrapper
+        * @deprecated since 1.32.
+        *
+        * @return IResultWrapper
         */
        public static function listAllPages() {
+               wfDeprecated( __METHOD__, '1.32' );
+
                $dbr = wfGetDB( DB_REPLICA );
 
                return self::listPages( $dbr, '' );
@@ -73,7 +92,7 @@ class PageArchive {
         * Returns result wrapper with (ar_namespace, ar_title, count) fields.
         *
         * @param string $term Search term
-        * @return ResultWrapper
+        * @return IResultWrapper
         */
        public static function listPagesBySearch( $term ) {
                $title = Title::newFromText( $term );
@@ -123,7 +142,7 @@ class PageArchive {
         * Returns result wrapper with (ar_namespace, ar_title, count) fields.
         *
         * @param string $prefix Title prefix
-        * @return ResultWrapper
+        * @return IResultWrapper
         */
        public static function listPagesByPrefix( $prefix ) {
                $dbr = wfGetDB( DB_REPLICA );
@@ -149,7 +168,7 @@ class PageArchive {
        /**
         * @param IDatabase $dbr
         * @param string|array $condition
-        * @return bool|ResultWrapper
+        * @return bool|IResultWrapper
         */
        protected static function listPages( $dbr, $condition ) {
                return $dbr->select(
@@ -173,17 +192,21 @@ class PageArchive {
         * List the revisions of the given page. Returns result wrapper with
         * various archive table fields.
         *
-        * @return ResultWrapper
+        * @return IResultWrapper
         */
        public function listRevisions() {
-               $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
+               $revisionStore = $this->getRevisionStore();
                $queryInfo = $revisionStore->getArchiveQueryInfo();
 
                $conds = [
                        'ar_namespace' => $this->title->getNamespace(),
                        'ar_title' => $this->title->getDBkey(),
                ];
-               $options = [ 'ORDER BY' => 'ar_timestamp DESC' ];
+
+               // NOTE: ordering by ar_timestamp and ar_id, to remove ambiguity.
+               // XXX: Ideally, we would be ordering by ar_timestamp and ar_rev_id, but since we
+               // don't have an index on ar_rev_id, that causes a file sort.
+               $options = [ 'ORDER BY' => 'ar_timestamp DESC, ar_id DESC' ];
 
                ChangeTags::modifyDisplayQuery(
                        $queryInfo['tables'],
@@ -210,7 +233,7 @@ class PageArchive {
         * Returns a result wrapper with various filearchive fields, or null
         * if not a file page.
         *
-        * @return ResultWrapper
+        * @return IResultWrapper
         * @todo Does this belong in Image for fuller encapsulation?
         */
        public function listFiles() {
@@ -232,30 +255,60 @@ class PageArchive {
 
        /**
         * Return a Revision object containing data for the deleted revision.
-        * Note that the result *may* or *may not* have a null page ID.
+        *
+        * @deprecated since 1.32, use getArchivedRevision() instead.
         *
         * @param string $timestamp
         * @return Revision|null
         */
        public function getRevision( $timestamp ) {
                $dbr = wfGetDB( DB_REPLICA );
-               $arQuery = Revision::getArchiveQueryInfo();
+               $rec = $this->getRevisionByConditions(
+                       [ 'ar_timestamp' => $dbr->timestamp( $timestamp ) ]
+               );
+               return $rec ? new Revision( $rec ) : null;
+       }
+
+       /**
+        * Return the archived revision with the given ID.
+        *
+        * @param int $revId
+        * @return Revision|null
+        */
+       public function getArchivedRevision( $revId ) {
+               // Protect against code switching from getRevision() passing in a timestamp.
+               Assert::parameterType( 'integer', $revId, '$revId' );
+
+               $rec = $this->getRevisionByConditions( [ 'ar_rev_id' => $revId ] );
+               return $rec ? new Revision( $rec ) : null;
+       }
+
+       /**
+        * @param array $conditions
+        * @param array $options
+        *
+        * @return RevisionRecord|null
+        */
+       private function getRevisionByConditions( array $conditions, array $options = [] ) {
+               $dbr = wfGetDB( DB_REPLICA );
+               $arQuery = $this->getRevisionStore()->getArchiveQueryInfo();
+
+               $conditions = $conditions + [
+                       'ar_namespace' => $this->title->getNamespace(),
+                       'ar_title' => $this->title->getDBkey(),
+               ];
 
                $row = $dbr->selectRow(
                        $arQuery['tables'],
                        $arQuery['fields'],
-                       [
-                               'ar_namespace' => $this->title->getNamespace(),
-                               'ar_title' => $this->title->getDBkey(),
-                               'ar_timestamp' => $dbr->timestamp( $timestamp )
-                       ],
+                       $conditions,
                        __METHOD__,
-                       [],
+                       $options,
                        $arQuery['joins']
                );
 
                if ( $row ) {
-                       return Revision::newFromArchiveRow( $row, [ 'title' => $this->title ] );
+                       return $this->getRevisionStore()->newRevisionFromArchiveRow( $row, 0, $this->title );
                }
 
                return null;
@@ -276,7 +329,7 @@ class PageArchive {
 
                // Check the previous deleted revision...
                $row = $dbr->selectRow( 'archive',
-                       'ar_timestamp',
+                       [ 'ar_id', 'ar_timestamp' ],
                        [ 'ar_namespace' => $this->title->getNamespace(),
                                'ar_title' => $this->title->getDBkey(),
                                'ar_timestamp < ' .
@@ -286,6 +339,7 @@ class PageArchive {
                                'ORDER BY' => 'ar_timestamp DESC',
                                'LIMIT' => 1 ] );
                $prevDeleted = $row ? wfTimestamp( TS_MW, $row->ar_timestamp ) : false;
+               $prevDeletedId = $row ? intval( $row->ar_rev_id ) : null;
 
                $row = $dbr->selectRow( [ 'page', 'revision' ],
                        [ 'rev_id', 'rev_timestamp' ],
@@ -304,31 +358,40 @@ class PageArchive {
 
                if ( $prevLive && $prevLive > $prevDeleted ) {
                        // Most prior revision was live
-                       return Revision::newFromId( $prevLiveId );
+                       $rec = $this->getRevisionStore()->getRevisionById( $prevLiveId );
+                       $rec = $rec ? new Revision( $rec ) : null;
                } elseif ( $prevDeleted ) {
                        // Most prior revision was deleted
-                       return $this->getRevision( $prevDeleted );
+                       $rec = $this->getArchivedRevision( $prevDeletedId );
+               } else {
+                       $rec = null;
                }
 
-               // No prior revision on this page.
-               return null;
+               return $rec;
        }
 
        /**
-        * Get the text from an archive row containing ar_text_id
+        * Get the text from an archive row containing ar_text_id.
+        *
+        * @deprecated since 1.32. In the MCR schema, ar_text_id no longer exists.
+        * Calling code should switch to getArchiveRevision().
+        *
+        * @todo remove in 1.33
         *
-        * @deprecated since 1.31
         * @param object $row Database row
         * @return string
         */
        public function getTextFromRow( $row ) {
-               $dbr = wfGetDB( DB_REPLICA );
-               $text = $dbr->selectRow( 'text',
-                       [ 'old_text', 'old_flags' ],
-                       [ 'old_id' => $row->ar_text_id ],
-                       __METHOD__ );
+               wfDeprecated( __METHOD__, '1.32' );
+
+               if ( empty( $row->ar_text_id ) ) {
+                       throw new InvalidArgumentException( '$row->ar_text_id must be set and not empty!' );
+               }
+
+               $address = SqlBlobStore::makeAddressFromTextId( $row->ar_text_id );
+               $blobStore = MediaWikiServices::getInstance()->getBlobStore();
 
-               return Revision::getRevisionText( $text );
+               return $blobStore->getBlob( $address );
        }
 
        /**
@@ -337,41 +400,65 @@ class PageArchive {
         *
         * If there are no archived revisions for the page, returns NULL.
         *
+        * @note this bypasses any audience checks.
+        *
+        * @deprecated since 1.32. For compatibility with the MCR schema,
+        * calling code should switch to getLastRevisionId() and getArchiveRevision().
+        *
+        * @todo remove in 1.33
+        *
         * @return string|null
         */
        public function getLastRevisionText() {
+               wfDeprecated( __METHOD__, '1.32' );
+
+               $revId = $this->getLastRevisionId();
+
+               if ( $revId ) {
+                       $rev = $this->getArchivedRevision( $revId );
+                       $content = $rev->getContent( RevisionRecord::RAW );
+                       return $content->serialize();
+               }
+
+               return null;
+       }
+
+       /**
+        * Returns the ID of the latest deleted revision.
+        *
+        * @return int|false The revision's ID, or false if there is no deleted revision.
+        */
+       public function getLastRevisionId() {
                $dbr = wfGetDB( DB_REPLICA );
-               $row = $dbr->selectRow(
-                       [ 'archive', 'text' ],
-                       [ 'old_text', 'old_flags' ],
+               $revId = $dbr->selectField(
+                       'archive',
+                       'ar_rev_id',
                        [ 'ar_namespace' => $this->title->getNamespace(),
                                'ar_title' => $this->title->getDBkey() ],
                        __METHOD__,
-                       [ 'ORDER BY' => 'ar_timestamp DESC, ar_id DESC' ],
-                       [ 'text' => [ 'JOIN', 'old_id = ar_text_id' ] ]
+                       [ 'ORDER BY' => 'ar_timestamp DESC, ar_id DESC' ]
                );
 
-               if ( $row ) {
-                       return Revision::getRevisionText( $row );
-               }
-
-               return null;
+               return $revId ? intval( $revId ) : false;
        }
 
        /**
         * Quick check if any archived revisions are present for the page.
+        * This says nothing about whether the page currently exists in the page table or not.
         *
         * @return bool
         */
        public function isDeleted() {
                $dbr = wfGetDB( DB_REPLICA );
-               $n = $dbr->selectField( 'archive', 'COUNT(ar_title)',
+               $row = $dbr->selectRow(
+                       [ 'archive' ],
+                       '1', // We don't care about the value. Allow the database to optimize.
                        [ 'ar_namespace' => $this->title->getNamespace(),
                                'ar_title' => $this->title->getDBkey() ],
                        __METHOD__
                );
 
-               return ( $n > 0 );
+               return (bool)$row;
        }
 
        /**
@@ -527,7 +614,7 @@ class PageArchive {
                        $oldWhere['ar_timestamp'] = array_map( [ &$dbw, 'timestamp' ], $timestamps );
                }
 
-               $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
+               $revisionStore = $this->getRevisionStore();
                $queryInfo = $revisionStore->getArchiveQueryInfo();
                $queryInfo['tables'][] = 'revision';
                $queryInfo['fields'][] = 'rev_id';
@@ -600,27 +687,35 @@ class PageArchive {
                if ( $latestRestorableRow !== null ) {
                        $oldPageId = (int)$latestRestorableRow->ar_page_id; // pass this to ArticleUndelete hook
 
-                       // grab the content to check consistency with global state before restoring the page.
-                       $revision = Revision::newFromArchiveRow( $latestRestorableRow,
-                               [
-                                       'title' => $article->getTitle(), // used to derive default content model
-                               ]
+                       // Grab the content to check consistency with global state before restoring the page.
+                       // XXX: The only current use case is Wikibase, which tries to enforce uniqueness of
+                       // certain things across all pages. There may be a better way to do that.
+                       $revision = $revisionStore->newRevisionFromArchiveRow(
+                               $latestRestorableRow,
+                               0,
+                               $this->title
                        );
-                       $user = User::newFromName( $revision->getUserText( Revision::RAW ), false );
-                       $content = $revision->getContent( Revision::RAW );
 
-                       // NOTE: article ID may not be known yet. prepareSave() should not modify the database.
-                       $status = $content->prepareSave( $article, 0, -1, $user );
-                       if ( !$status->isOK() ) {
-                               $dbw->endAtomic( __METHOD__ );
+                       // TODO: use User::newFromUserIdentity from If610c68f4912e
+                       // TODO: The User isn't used for anything in prepareSave()! We should drop it.
+                       $user = User::newFromName( $revision->getUser( RevisionRecord::RAW )->getName(), false );
 
-                               return $status;
+                       foreach ( $revision->getSlotRoles() as $role ) {
+                               $content = $revision->getContent( $role, RevisionRecord::RAW );
+
+                               // NOTE: article ID may not be known yet. prepareSave() should not modify the database.
+                               $status = $content->prepareSave( $article, 0, -1, $user );
+                               if ( !$status->isOK() ) {
+                                       $dbw->endAtomic( __METHOD__ );
+
+                                       return $status;
+                               }
                        }
                }
 
                $newid = false; // newly created page ID
                $restored = 0; // number of revisions restored
-               /** @var Revision $revision */
+               /** @var RevisionRecord|null $revision */
                $revision = null;
                $restoredPages = [];
                // If there are no restorable revisions, we can skip most of the steps.
@@ -630,7 +725,7 @@ class PageArchive {
                        if ( $makepage ) {
                                // Check the state of the newest to-be version...
                                if ( !$unsuppress
-                                       && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT )
+                                       && ( $latestRestorableRow->ar_deleted & RevisionRecord::DELETED_TEXT )
                                ) {
                                        $dbw->endAtomic( __METHOD__ );
 
@@ -648,7 +743,7 @@ class PageArchive {
                                if ( $latestRestorableRow->ar_timestamp > $previousTimestamp ) {
                                        // Check the state of the newest to-be version...
                                        if ( !$unsuppress
-                                               && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT )
+                                               && ( $latestRestorableRow->ar_deleted & RevisionRecord::DELETED_TEXT )
                                        ) {
                                                $dbw->endAtomic( __METHOD__ );
 
@@ -667,20 +762,24 @@ class PageArchive {
                                }
                                // Insert one revision at a time...maintaining deletion status
                                // unless we are specifically removing all restrictions...
-                               $revision = Revision::newFromArchiveRow( $row,
+                               $revision = $revisionStore->newRevisionFromArchiveRow(
+                                       $row,
+                                       0,
+                                       $this->title,
                                        [
                                                'page' => $pageId,
-                                               'title' => $this->title,
                                                'deleted' => $unsuppress ? 0 : $row->ar_deleted
-                                       ] );
+                                       ]
+                               );
 
                                // This will also copy the revision to ip_changes if it was an IP edit.
-                               $revision->insertOn( $dbw );
+                               $revisionStore->insertRevisionOn( $revision, $dbw );
 
                                $restored++;
 
+                               $legacyRevision = new Revision( $revision );
                                Hooks::run( 'ArticleRevisionUndeleted',
-                                       [ &$this->title, $revision, $row->ar_page_id ] );
+                                       [ &$this->title, $legacyRevision, $row->ar_page_id ] );
                                $restoredPages[$row->ar_page_id] = true;
                        }
 
@@ -708,12 +807,14 @@ class PageArchive {
                if ( $restored ) {
                        $created = (bool)$newid;
                        // Attach the latest revision to the page...
-                       $wasnew = $article->updateIfNewerOn( $dbw, $revision );
+                       // XXX: updateRevisionOn should probably move into a PageStore service.
+                       $wasnew = $article->updateIfNewerOn( $dbw, $legacyRevision );
                        if ( $created || $wasnew ) {
                                // Update site stats, link tables, etc
+                               // TODO: use DerivedPageDataUpdater from If610c68f4912e!
                                $article->doEditUpdates(
-                                       $revision,
-                                       User::newFromName( $revision->getUserText( Revision::RAW ), false ),
+                                       $legacyRevision,
+                                       User::newFromName( $revision->getUser( RevisionRecord::RAW )->getName(), false ),
                                        [
                                                'created' => $created,
                                                'oldcountable' => $oldcountable,
index f716e92..41a059f 100644 (file)
@@ -756,45 +756,36 @@ class SpecialWatchlist extends ChangesListSpecialPage {
        }
 
        function cutoffselector( $options ) {
-               // Cast everything to strings immediately, so that we know all of the values have the same
-               // precision, and can be compared with '==='. 2/24 has a few more decimal places than its
-               // default string representation, for example, and would confuse comparisons.
-
-               // Misleadingly, the 'days' option supports hours too.
-               $days = array_map( 'strval', [ 1 / 24, 2 / 24, 6 / 24, 12 / 24, 1, 3, 7 ] );
-
-               $userWatchlistOption = (string)$this->getUser()->getOption( 'watchlistdays' );
-               // add the user preference, if it isn't available already
-               if ( !in_array( $userWatchlistOption, $days ) && $userWatchlistOption !== '0' ) {
-                       $days[] = $userWatchlistOption;
-               }
-
-               $maxDays = (string)$this->maxDays;
-               // add the maximum possible value, if it isn't available already
-               if ( !in_array( $maxDays, $days ) ) {
-                       $days[] = $maxDays;
-               }
-
-               $selected = (string)$options['days'];
+               $selected = (float)$options['days'];
                if ( $selected <= 0 ) {
-                       $selected = $maxDays;
-               }
-
-               // add the currently selected value, if it isn't available already
-               if ( !in_array( $selected, $days ) ) {
-                       $days[] = $selected;
-               }
+                       $selected = $this->maxDays;
+               }
+
+               $selectedHours = round( $selected * 24 );
+
+               $hours = array_unique( array_filter( [
+                       1,
+                       2,
+                       6,
+                       12,
+                       24,
+                       72,
+                       168,
+                       24 * (float)$this->getUser()->getOption( 'watchlistdays', 0 ),
+                       24 * $this->maxDays,
+                       $selectedHours
+               ] ) );
+               asort( $hours );
 
-               $select = new XmlSelect( 'days', 'days', $selected );
+               $select = new XmlSelect( 'days', 'days', $selectedHours / 24 );
 
-               asort( $days );
-               foreach ( $days as $value ) {
-                       if ( $value < 1 ) {
-                               $name = $this->msg( 'hours' )->numParams( $value * 24 )->text();
+               foreach ( $hours as $value ) {
+                       if ( $value < 24 ) {
+                               $name = $this->msg( 'hours' )->numParams( $value )->text();
                        } else {
-                               $name = $this->msg( 'days' )->numParams( $value )->text();
+                               $name = $this->msg( 'days' )->numParams( $value / 24 )->text();
                        }
-                       $select->addOption( $name, $value );
+                       $select->addOption( $name, $value / 24 );
                }
 
                return $select->getHTML() . "\n<br />\n";
index 3253957..8373ffc 100644 (file)
@@ -1883,6 +1883,14 @@ class Language {
                        # Add 543 years to the Gregorian calendar
                        # Months and days are identical
                        $gy_offset = $gy + 543;
+                       # fix for dates between 1912 and 1941
+                       # https://en.wikipedia.org/?oldid=836596673#New_year
+                       if ( $gy >= 1912 && $gy <= 1940 ) {
+                               if ( $gm <= 3 ) {
+                                       $gy_offset--;
+                               }
+                               $gm = ( $gm - 3 ) % 12;
+                       }
                } elseif ( ( !strcmp( $cName, 'minguo' ) ) || !strcmp( $cName, 'juche' ) ) {
                        # Minguo dates
                        # Deduct 1911 years from the Gregorian calendar
@@ -4400,7 +4408,7 @@ class Language {
         * @return bool
         */
        public function equals( Language $lang ) {
-               return $lang->getCode() === $this->mCode;
+               return $lang === $this || $lang->getCode() === $this->mCode;
        }
 
        /**
index e2030c9..b30a30e 100644 (file)
                        Object.keys( inspect.reports );
 
                reports.forEach( function ( name ) {
+                       if ( console.group ) {
+                               console.group( 'mw.inspect ' + name + ' report' );
+                       } else {
+                               console.log( 'mw.inspect ' + name + ' report' );
+                       }
                        inspect.dumpTable( inspect.reports[ name ]() );
+                       if ( console.group ) {
+                               console.groupEnd( 'mw.inspect ' + name + ' report' );
+                       }
                } );
        };
 
index 795a2b7..11ed425 100644 (file)
                $( '<div>' ).addClass( 'mw-navigation-hint' )
                        .text( mw.msg( 'prefs-tabs-navigation-hint' ) )
                        .attr( 'tabIndex', 0 )
-                       .on( 'focus blur', function ( e ) {
-                               if ( e.type === 'blur' || e.type === 'focusout' ) {
-                                       $( this ).css( 'height', '0' );
-                               } else {
-                                       $( this ).css( 'height', 'auto' );
-                               }
-                       } ).prependTo( '#mw-content-text' );
+                       .prependTo( '#mw-content-text' );
 
                tabs = new OO.ui.IndexLayout( {
                        expanded: false,
index ecf6887..fac53f3 100644 (file)
 /*
  * Hide, but keep accessible for screen-readers.
  */
-.client-js .mw-navigation-hint {
-       overflow: hidden;
-       height: 0;
-       zoom: 1;
+.client-js .mw-navigation-hint:not( :focus ) {
+       .mixin-screen-reader-text;
 }
 
 /* Override OOUI styles so that dropdowns near the bottom of the form don't get clipped,
index 3911faa..06d789b 100644 (file)
@@ -67,6 +67,7 @@ $wgAutoloadClasses += [
        'HamcrestPHPUnitIntegration' => "$testDir/phpunit/HamcrestPHPUnitIntegration.php",
 
        # tests/phpunit/includes
+       'PageArchiveTestBase' => "$testDir/phpunit/includes/page/PageArchiveTestBase.php",
        'RevisionDbTestBase' => "$testDir/phpunit/includes/RevisionDbTestBase.php",
        'RevisionTestModifyableContent' => "$testDir/phpunit/includes/RevisionTestModifyableContent.php",
        'RevisionTestModifyableContentHandler' => "$testDir/phpunit/includes/RevisionTestModifyableContentHandler.php",
index 3f24030..c383be6 100644 (file)
@@ -92,6 +92,12 @@ class ContentSecurityPolicyTest extends MediaWikiTestCase {
                // @codingStandardsIgnoreStart Generic.Files.LineLength
                return [
                        [ false, '', '', '' ],
+                       [
+                               [ 'useNonces' => false ],
+                               "script-src 'unsafe-eval' 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&",
+                               "script-src 'unsafe-eval' 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1&",
+                               "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'"
+                       ],
                        [
                                true,
                                "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&",
@@ -283,14 +289,14 @@ class ContentSecurityPolicyTest extends MediaWikiTestCase {
 
        /**
         * @dataProvider providerCSPIsEnabled
-        * @covers ContentSecurityPolicy::isEnabled
+        * @covers ContentSecurityPolicy::isNonceRequired
         */
        public function testCSPIsEnabled( $main, $reportOnly, $expected ) {
                global $wgCSPReportOnlyHeader, $wgCSPHeader;
                global $wgCSPHeader;
                $oldReport = wfSetVar( $wgCSPReportOnlyHeader, $reportOnly );
                $oldMain = wfSetVar( $wgCSPHeader, $main );
-               $res = ContentSecurityPolicy::isEnabled( RequestContext::getMain()->getConfig() );
+               $res = ContentSecurityPolicy::isNonceRequired( RequestContext::getMain()->getConfig() );
                wfSetVar( $wgCSPReportOnlyHeader, $oldReport );
                wfSetVar( $wgCSPHeader, $oldMain );
                $this->assertEquals( $res, $expected );
@@ -305,6 +311,9 @@ class ContentSecurityPolicyTest extends MediaWikiTestCase {
                        [ false, [], true ],
                        [ [], false, true ],
                        [ [ 'default-src' => [ 'foo.example.com' ] ], false, true ],
+                       [ [ 'useNonces' => false ], [ 'useNonces' => false ], false ],
+                       [ [ 'useNonces' => true ], [ 'useNonces' => false ], true ],
+                       [ [ 'useNonces' => false ], [ 'useNonces' => true ], true ],
                ];
        }
 }
diff --git a/tests/phpunit/includes/PageArchiveTest.php b/tests/phpunit/includes/PageArchiveTest.php
deleted file mode 100644 (file)
index 15a991e..0000000
+++ /dev/null
@@ -1,267 +0,0 @@
-<?php
-
-/**
- * Test class for page archiving.
- *
- * @group ContentHandler
- * @group Database
- * ^--- important, causes temporary tables to be used instead of the real database
- *
- * @group medium
- * ^--- important, causes tests not to fail with timeout
- */
-class PageArchiveTest extends MediaWikiTestCase {
-
-       /**
-        * @var PageArchive $archivedPage
-        */
-       private $archivedPage;
-
-       /**
-        * A logged out user who edited the page before it was archived.
-        * @var string $ipEditor
-        */
-       private $ipEditor;
-
-       /**
-        * Revision ID of the IP edit
-        * @var int $ipRevId
-        */
-       private $ipRevId;
-
-       function __construct( $name = null, array $data = [], $dataName = '' ) {
-               parent::__construct( $name, $data, $dataName );
-
-               $this->tablesUsed = array_merge(
-                       $this->tablesUsed,
-                       [
-                               'page',
-                               'revision',
-                               'ip_changes',
-                               'text',
-                               'archive',
-                               'recentchanges',
-                               'logging',
-                               'page_props',
-                       ]
-               );
-       }
-
-       protected function setUp() {
-               parent::setUp();
-
-               $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
-               $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
-               $this->setMwGlobals( 'wgMultiContentRevisionSchemaMigrationStage', SCHEMA_COMPAT_OLD );
-               $this->overrideMwServices();
-
-               // First create our dummy page
-               $page = Title::newFromText( 'PageArchiveTest_thePage' );
-               $page = new WikiPage( $page );
-               $content = ContentHandler::makeContent(
-                       'testing',
-                       $page->getTitle(),
-                       CONTENT_MODEL_WIKITEXT
-               );
-               $page->doEditContent( $content, 'testing', EDIT_NEW );
-
-               // Insert IP revision
-               $this->ipEditor = '2600:387:ed7:947e:8c16:a1ad:dd34:1dd7';
-               $rev = new Revision( [
-                       'text' => 'Lorem Ipsum',
-                       'comment' => 'just a test',
-                       'page' => $page->getId(),
-                       'user_text' => $this->ipEditor,
-               ] );
-               $dbw = wfGetDB( DB_MASTER );
-               $this->ipRevId = $rev->insertOn( $dbw );
-
-               // Delete the page
-               $page->doDeleteArticleReal( 'Just a test deletion' );
-
-               $this->archivedPage = new PageArchive( $page->getTitle() );
-       }
-
-       /**
-        * @covers PageArchive::undelete
-        * @covers PageArchive::undeleteRevisions
-        */
-       public function testUndeleteRevisions() {
-               // First make sure old revisions are archived
-               $dbr = wfGetDB( DB_REPLICA );
-               $arQuery = Revision::getArchiveQueryInfo();
-               $res = $dbr->select(
-                       $arQuery['tables'],
-                       $arQuery['fields'],
-                       [ 'ar_rev_id' => $this->ipRevId ],
-                       __METHOD__,
-                       [],
-                       $arQuery['joins']
-               );
-               $row = $res->fetchObject();
-               $this->assertEquals( $this->ipEditor, $row->ar_user_text );
-
-               // Should not be in revision
-               $res = $dbr->select( 'revision', '1', [ 'rev_id' => $this->ipRevId ] );
-               $this->assertFalse( $res->fetchObject() );
-
-               // Should not be in ip_changes
-               $res = $dbr->select( 'ip_changes', '1', [ 'ipc_rev_id' => $this->ipRevId ] );
-               $this->assertFalse( $res->fetchObject() );
-
-               // Restore the page
-               $this->archivedPage->undelete( [] );
-
-               // Should be back in revision
-               $revQuery = Revision::getQueryInfo();
-               $res = $dbr->select(
-                       $revQuery['tables'],
-                       $revQuery['fields'],
-                       [ 'rev_id' => $this->ipRevId ],
-                       __METHOD__,
-                       [],
-                       $revQuery['joins']
-               );
-               $row = $res->fetchObject();
-               $this->assertEquals( $this->ipEditor, $row->rev_user_text );
-
-               // Should be back in ip_changes
-               $res = $dbr->select( 'ip_changes', [ 'ipc_hex' ], [ 'ipc_rev_id' => $this->ipRevId ] );
-               $row = $res->fetchObject();
-               $this->assertEquals( IP::toHex( $this->ipEditor ), $row->ipc_hex );
-       }
-
-       /**
-        * @covers PageArchive::listRevisions
-        */
-       public function testListRevisions() {
-               $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
-               $this->setMwGlobals( 'wgMultiContentRevisionSchemaMigrationStage', SCHEMA_COMPAT_OLD );
-               $this->overrideMwServices();
-
-               $revisions = $this->archivedPage->listRevisions();
-               $this->assertEquals( 2, $revisions->numRows() );
-
-               // Get the rows as arrays
-               $row1 = (array)$revisions->current();
-               $row2 = (array)$revisions->next();
-               // Unset the timestamps (we assume they will be right...
-               $this->assertInternalType( 'string', $row1['ar_timestamp'] );
-               $this->assertInternalType( 'string', $row2['ar_timestamp'] );
-               unset( $row1['ar_timestamp'] );
-               unset( $row2['ar_timestamp'] );
-
-               $this->assertEquals(
-                       [
-                               'ar_minor_edit' => '0',
-                               'ar_user' => '0',
-                               'ar_user_text' => '2600:387:ed7:947e:8c16:a1ad:dd34:1dd7',
-                               'ar_actor' => null,
-                               'ar_len' => '11',
-                               'ar_deleted' => '0',
-                               'ar_rev_id' => '3',
-                               'ar_sha1' => '0qdrpxl537ivfnx4gcpnzz0285yxryy',
-                               'ar_page_id' => '2',
-                               'ar_comment_text' => 'just a test',
-                               'ar_comment_data' => null,
-                               'ar_comment_cid' => null,
-                               'ar_content_format' => null,
-                               'ar_content_model' => null,
-                               'ts_tags' => null,
-                               'ar_id' => '2',
-                               'ar_namespace' => '0',
-                               'ar_title' => 'PageArchiveTest_thePage',
-                               'ar_text_id' => '3',
-                               'ar_parent_id' => '2',
-                       ],
-                       $row1
-               );
-               $this->assertEquals(
-                       [
-                               'ar_minor_edit' => '0',
-                               'ar_user' => '0',
-                               'ar_user_text' => '127.0.0.1',
-                               'ar_actor' => null,
-                               'ar_len' => '7',
-                               'ar_deleted' => '0',
-                               'ar_rev_id' => '2',
-                               'ar_sha1' => 'pr0s8e18148pxhgjfa0gjrvpy8fiyxc',
-                               'ar_page_id' => '2',
-                               'ar_comment_text' => 'testing',
-                               'ar_comment_data' => null,
-                               'ar_comment_cid' => null,
-                               'ar_content_format' => null,
-                               'ar_content_model' => null,
-                               'ts_tags' => null,
-                               'ar_id' => '1',
-                               'ar_namespace' => '0',
-                               'ar_title' => 'PageArchiveTest_thePage',
-                               'ar_text_id' => '2',
-                               'ar_parent_id' => '0',
-                       ],
-                       $row2
-               );
-       }
-
-       /**
-        * @covers PageArchive::listPagesBySearch
-        */
-       public function testListPagesBySearch() {
-               $pages = PageArchive::listPagesBySearch( 'PageArchiveTest_thePage' );
-               $this->assertSame( 1, $pages->numRows() );
-
-               $page = (array)$pages->current();
-
-               $this->assertSame(
-                       [
-                               'ar_namespace' => '0',
-                               'ar_title' => 'PageArchiveTest_thePage',
-                               'count' => '2',
-                       ],
-                       $page
-               );
-       }
-
-       /**
-        * @covers PageArchive::listPagesBySearch
-        */
-       public function testListPagesByPrefix() {
-               $pages = PageArchive::listPagesByPrefix( 'PageArchiveTest' );
-               $this->assertSame( 1, $pages->numRows() );
-
-               $page = (array)$pages->current();
-
-               $this->assertSame(
-                       [
-                               'ar_namespace' => '0',
-                               'ar_title' => 'PageArchiveTest_thePage',
-                               'count' => '2',
-                       ],
-                       $page
-               );
-       }
-
-       /**
-        * @covers PageArchive::getTextFromRow
-        */
-       public function testGetTextFromRow() {
-               $row = (object)[ 'ar_text_id' => 2 ];
-               $text = $this->archivedPage->getTextFromRow( $row );
-               $this->assertSame( 'testing', $text );
-       }
-
-       /**
-        * @covers PageArchive::getLastRevisionText
-        */
-       public function testGetLastRevisionText() {
-               $text = $this->archivedPage->getLastRevisionText();
-               $this->assertSame( 'Lorem Ipsum', $text );
-       }
-
-       /**
-        * @covers PageArchive::isDeleted
-        */
-       public function testIsDeleted() {
-               $this->assertTrue( $this->archivedPage->isDeleted() );
-       }
-}
diff --git a/tests/phpunit/includes/page/PageArchiveMcrTest.php b/tests/phpunit/includes/page/PageArchiveMcrTest.php
new file mode 100644 (file)
index 0000000..d2a8016
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Tests\Storage\McrSchemaOverride;
+
+/**
+ * Test class for page archiving, using the new MCR schema.
+ *
+ * @group ContentHandler
+ * @group Database
+ * ^--- important, causes temporary tables to be used instead of the real database
+ *
+ * @group medium
+ * ^--- important, causes tests not to fail with timeout
+ */
+class PageArchiveMcrTest extends PageArchiveTestBase {
+
+       use McrSchemaOverride;
+
+       /**
+        * @covers PageArchive::listRevisions
+        */
+       public function testListRevisions_slots() {
+               $revisions = $this->archivedPage->listRevisions();
+
+               $revisionStore = MediaWikiServices::getInstance()->getInstance()->getRevisionStore();
+               $slotsQuery = $revisionStore->getSlotsQueryInfo( [ 'content' ] );
+
+               foreach ( $revisions as $row ) {
+                       $this->assertSelect(
+                               $slotsQuery['tables'],
+                               'count(*)',
+                               [ 'slot_revision_id' => $row->ar_rev_id ],
+                               [ [ 1 ] ],
+                               [],
+                               $slotsQuery['joins']
+                       );
+               }
+       }
+
+       protected function getExpectedArchiveRows() {
+               return [
+                       [
+                               'ar_minor_edit' => '0',
+                               'ar_user' => '0',
+                               'ar_user_text' => $this->ipEditor,
+                               'ar_actor' => null,
+                               'ar_len' => '11',
+                               'ar_deleted' => '0',
+                               'ar_rev_id' => strval( $this->ipRev->getId() ),
+                               'ar_timestamp' => $this->db->timestamp( $this->ipRev->getTimestamp() ),
+                               'ar_sha1' => '0qdrpxl537ivfnx4gcpnzz0285yxryy',
+                               'ar_page_id' => strval( $this->ipRev->getPageId() ),
+                               'ar_comment_text' => 'just a test',
+                               'ar_comment_data' => null,
+                               'ar_comment_cid' => null,
+                               'ts_tags' => null,
+                               'ar_id' => '2',
+                               'ar_namespace' => '0',
+                               'ar_title' => 'PageArchiveTest_thePage',
+                               'ar_parent_id' => strval( $this->ipRev->getParentId() ),
+                       ],
+                       [
+                               'ar_minor_edit' => '0',
+                               'ar_user' => (string)$this->getTestUser()->getUser()->getId(),
+                               'ar_user_text' => $this->getTestUser()->getUser()->getName(),
+                               'ar_actor' => null,
+                               'ar_len' => '7',
+                               'ar_deleted' => '0',
+                               'ar_rev_id' => strval( $this->firstRev->getId() ),
+                               'ar_timestamp' => $this->db->timestamp( $this->firstRev->getTimestamp() ),
+                               'ar_sha1' => 'pr0s8e18148pxhgjfa0gjrvpy8fiyxc',
+                               'ar_page_id' => strval( $this->firstRev->getPageId() ),
+                               'ar_comment_text' => 'testing',
+                               'ar_comment_data' => null,
+                               'ar_comment_cid' => null,
+                               'ts_tags' => null,
+                               'ar_id' => '1',
+                               'ar_namespace' => '0',
+                               'ar_title' => 'PageArchiveTest_thePage',
+                               'ar_parent_id' => '0',
+                       ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/page/PageArchivePreMcrTest.php b/tests/phpunit/includes/page/PageArchivePreMcrTest.php
new file mode 100644 (file)
index 0000000..6757e78
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\SqlBlobStore;
+use MediaWiki\Tests\Storage\PreMcrSchemaOverride;
+
+/**
+ * Test class for page archiving, using the pre-MCR schema.
+ *
+ * @group ContentHandler
+ * @group Database
+ * ^--- important, causes temporary tables to be used instead of the real database
+ *
+ * @group medium
+ * ^--- important, causes tests not to fail with timeout
+ */
+class PageArchivePreMcrTest extends PageArchiveTestBase {
+
+       use PreMcrSchemaOverride;
+
+       /**
+        * @covers PageArchive::getTextFromRow
+        */
+       public function testGetTextFromRow() {
+               $this->hideDeprecated( PageArchive::class . '::getTextFromRow' );
+
+               /** @var SqlBlobStore $blobStore */
+               $blobStore = MediaWikiServices::getInstance()->getBlobStore();
+
+               $textId = $blobStore->getTextIdFromAddress(
+                       $this->firstRev->getSlot( 'main' )->getAddress()
+               );
+
+               $row = (object)[ 'ar_text_id' => $textId ];
+               $text = $this->archivedPage->getTextFromRow( $row );
+               $this->assertSame( 'testing', $text );
+       }
+
+       protected function getExpectedArchiveRows() {
+               /** @var SqlBlobStore $blobStore */
+               $blobStore = MediaWikiServices::getInstance()->getBlobStore();
+
+               return [
+                       [
+                               'ar_minor_edit' => '0',
+                               'ar_user' => '0',
+                               'ar_user_text' => $this->ipEditor,
+                               'ar_actor' => null,
+                               'ar_len' => '11',
+                               'ar_deleted' => '0',
+                               'ar_rev_id' => strval( $this->ipRev->getId() ),
+                               'ar_timestamp' => $this->db->timestamp( $this->ipRev->getTimestamp() ),
+                               'ar_sha1' => '0qdrpxl537ivfnx4gcpnzz0285yxryy',
+                               'ar_page_id' => strval( $this->ipRev->getPageId() ),
+                               'ar_comment_text' => 'just a test',
+                               'ar_comment_data' => null,
+                               'ar_comment_cid' => null,
+                               'ar_content_format' => null,
+                               'ar_content_model' => null,
+                               'ts_tags' => null,
+                               'ar_id' => '2',
+                               'ar_namespace' => '0',
+                               'ar_title' => 'PageArchiveTest_thePage',
+                               'ar_text_id' => (string)$blobStore->getTextIdFromAddress(
+                                       $this->ipRev->getSlot( 'main' )->getAddress()
+                               ),
+                               'ar_parent_id' => strval( $this->ipRev->getParentId() ),
+                       ],
+                       [
+                               'ar_minor_edit' => '0',
+                               'ar_user' => (string)$this->getTestUser()->getUser()->getId(),
+                               'ar_user_text' => $this->getTestUser()->getUser()->getName(),
+                               'ar_actor' => null,
+                               'ar_len' => '7',
+                               'ar_deleted' => '0',
+                               'ar_rev_id' => strval( $this->firstRev->getId() ),
+                               'ar_timestamp' => $this->db->timestamp( $this->firstRev->getTimestamp() ),
+                               'ar_sha1' => 'pr0s8e18148pxhgjfa0gjrvpy8fiyxc',
+                               'ar_page_id' => strval( $this->firstRev->getPageId() ),
+                               'ar_comment_text' => 'testing',
+                               'ar_comment_data' => null,
+                               'ar_comment_cid' => null,
+                               'ar_content_format' => null,
+                               'ar_content_model' => null,
+                               'ts_tags' => null,
+                               'ar_id' => '1',
+                               'ar_namespace' => '0',
+                               'ar_title' => 'PageArchiveTest_thePage',
+                               'ar_text_id' => (string)$blobStore->getTextIdFromAddress(
+                                       $this->firstRev->getSlot( 'main' )->getAddress()
+                               ),
+                               'ar_parent_id' => '0',
+                       ],
+               ];
+       }
+
+}
diff --git a/tests/phpunit/includes/page/PageArchiveTestBase.php b/tests/phpunit/includes/page/PageArchiveTestBase.php
new file mode 100644 (file)
index 0000000..5a666a8
--- /dev/null
@@ -0,0 +1,315 @@
+<?php
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\RevisionRecord;
+
+/**
+ * Base class for tests of PageArchive against different database schemas.
+ */
+abstract class PageArchiveTestBase extends MediaWikiTestCase {
+
+       /**
+        * @var int
+        */
+       protected $pageId;
+
+       /**
+        * @var PageArchive $archivedPage
+        */
+       protected $archivedPage;
+
+       /**
+        * A logged out user who edited the page before it was archived.
+        * @var string $ipEditor
+        */
+       protected $ipEditor;
+
+       /**
+        * Revision of the first (initial) edit
+        * @var RevisionRecord
+        */
+       protected $firstRev;
+
+       /**
+        * Revision of the IP edit (the second edit)
+        * @var RevisionRecord
+        */
+       protected $ipRev;
+
+       function __construct( $name = null, array $data = [], $dataName = '' ) {
+               parent::__construct( $name, $data, $dataName );
+
+               $this->tablesUsed = array_merge(
+                       $this->tablesUsed,
+                       [
+                               'page',
+                               'revision',
+                               'ip_changes',
+                               'text',
+                               'archive',
+                               'recentchanges',
+                               'logging',
+                               'page_props',
+                       ]
+               );
+       }
+
+       protected function addCoreDBData() {
+               // Blank out to avoid failures when schema overrides imposed by subclasses
+               // affect revision storage.
+       }
+
+       /**
+        * @return int
+        */
+       abstract protected function getMcrMigrationStage();
+
+       /**
+        * @return string[]
+        */
+       abstract protected function getMcrTablesToReset();
+
+       /**
+        * @return bool
+        */
+       protected function getContentHandlerUseDB() {
+               return true;
+       }
+
+       protected function setUp() {
+               parent::setUp();
+
+               $this->tablesUsed += $this->getMcrTablesToReset();
+
+               $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
+               $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
+               $this->setMwGlobals( 'wgContentHandlerUseDB', $this->getContentHandlerUseDB() );
+               $this->setMwGlobals(
+                       'wgMultiContentRevisionSchemaMigrationStage',
+                       $this->getMcrMigrationStage()
+               );
+               $this->overrideMwServices();
+
+               // First create our dummy page
+               $page = Title::newFromText( 'PageArchiveTest_thePage' );
+               $page = new WikiPage( $page );
+               $content = ContentHandler::makeContent(
+                       'testing',
+                       $page->getTitle(),
+                       CONTENT_MODEL_WIKITEXT
+               );
+
+               $user = $this->getTestUser()->getUser();
+               $page->doEditContent( $content, 'testing', EDIT_NEW, false, $user );
+
+               $this->pageId = $page->getId();
+               $this->firstRev = $page->getRevision()->getRevisionRecord();
+
+               // Insert IP revision
+               $this->ipEditor = '2001:db8::1';
+
+               $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
+
+               $ipTimestamp = wfTimestamp(
+                       TS_MW,
+                       wfTimestamp( TS_UNIX, $this->firstRev->getTimestamp() ) + 1
+               );
+
+               $rev = $revisionStore->newMutableRevisionFromArray( [
+                       'text' => 'Lorem Ipsum',
+                       'comment' => 'just a test',
+                       'page' => $page->getId(),
+                       'user_text' => $this->ipEditor,
+                       'timestamp' => $ipTimestamp,
+               ] );
+
+               $dbw = wfGetDB( DB_MASTER );
+               $this->ipRev = $revisionStore->insertRevisionOn( $rev, $dbw );
+
+               // Delete the page
+               $page->doDeleteArticleReal( 'Just a test deletion' );
+
+               $this->archivedPage = new PageArchive( $page->getTitle() );
+       }
+
+       /**
+        * @covers PageArchive::undelete
+        * @covers PageArchive::undeleteRevisions
+        */
+       public function testUndeleteRevisions() {
+               // TODO: MCR: Test undeletion with multiple slots. Check that slots remain untouched.
+
+               // First make sure old revisions are archived
+               $dbr = wfGetDB( DB_REPLICA );
+               $arQuery = Revision::getArchiveQueryInfo();
+               $row = $dbr->selectRow(
+                       $arQuery['tables'],
+                       $arQuery['fields'],
+                       [ 'ar_rev_id' => $this->ipRev->getId() ],
+                       __METHOD__,
+                       [],
+                       $arQuery['joins']
+               );
+               $this->assertEquals( $this->ipEditor, $row->ar_user_text );
+
+               // Should not be in revision
+               $row = $dbr->selectRow( 'revision', '1', [ 'rev_id' => $this->ipRev->getId() ] );
+               $this->assertFalse( $row );
+
+               // Should not be in ip_changes
+               $row = $dbr->selectRow( 'ip_changes', '1', [ 'ipc_rev_id' => $this->ipRev->getId() ] );
+               $this->assertFalse( $row );
+
+               // Restore the page
+               $this->archivedPage->undelete( [] );
+
+               // Should be back in revision
+               $revQuery = Revision::getQueryInfo();
+               $row = $dbr->selectRow(
+                       $revQuery['tables'],
+                       $revQuery['fields'],
+                       [ 'rev_id' => $this->ipRev->getId() ],
+                       __METHOD__,
+                       [],
+                       $revQuery['joins']
+               );
+               $this->assertNotFalse( $row, 'row exists in revision table' );
+               $this->assertEquals( $this->ipEditor, $row->rev_user_text );
+
+               // Should be back in ip_changes
+               $row = $dbr->selectRow( 'ip_changes', [ 'ipc_hex' ], [ 'ipc_rev_id' => $this->ipRev->getId() ] );
+               $this->assertNotFalse( $row, 'row exists in ip_changes table' );
+               $this->assertEquals( IP::toHex( $this->ipEditor ), $row->ipc_hex );
+       }
+
+       abstract protected function getExpectedArchiveRows();
+
+       /**
+        * @covers PageArchive::listRevisions
+        */
+       public function testListRevisions() {
+               $revisions = $this->archivedPage->listRevisions();
+               $this->assertEquals( 2, $revisions->numRows() );
+
+               // Get the rows as arrays
+               $row0 = (array)$revisions->current();
+               $row1 = (array)$revisions->next();
+
+               $expectedRows = $this->getExpectedArchiveRows();
+
+               $this->assertEquals(
+                       $expectedRows[0],
+                       $row0
+               );
+               $this->assertEquals(
+                       $expectedRows[1],
+                       $row1
+               );
+       }
+
+       /**
+        * @covers PageArchive::listPagesBySearch
+        */
+       public function testListPagesBySearch() {
+               $pages = PageArchive::listPagesBySearch( 'PageArchiveTest_thePage' );
+               $this->assertSame( 1, $pages->numRows() );
+
+               $page = (array)$pages->current();
+
+               $this->assertSame(
+                       [
+                               'ar_namespace' => '0',
+                               'ar_title' => 'PageArchiveTest_thePage',
+                               'count' => '2',
+                       ],
+                       $page
+               );
+       }
+
+       /**
+        * @covers PageArchive::listPagesBySearch
+        */
+       public function testListPagesByPrefix() {
+               $pages = PageArchive::listPagesByPrefix( 'PageArchiveTest' );
+               $this->assertSame( 1, $pages->numRows() );
+
+               $page = (array)$pages->current();
+
+               $this->assertSame(
+                       [
+                               'ar_namespace' => '0',
+                               'ar_title' => 'PageArchiveTest_thePage',
+                               'count' => '2',
+                       ],
+                       $page
+               );
+       }
+
+       public function provideGetTextFromRowThrowsInvalidArgumentException() {
+               yield 'missing ar_text_id field' => [ [] ];
+               yield 'ar_text_id is null' => [ [ 'ar_text_id' => null ] ];
+               yield 'ar_text_id is zero' => [ [ 'ar_text_id' => 0 ] ];
+               yield 'ar_text_id is "0"' => [ [ 'ar_text_id' => '0' ] ];
+       }
+
+       /**
+        * @dataProvider provideGetTextFromRowThrowsInvalidArgumentException
+        * @covers PageArchive::getTextFromRow
+        */
+       public function testGetTextFromRowThrowsInvalidArgumentException( array $row ) {
+               $this->hideDeprecated( PageArchive::class . '::getTextFromRow' );
+               $this->setExpectedException( InvalidArgumentException::class );
+
+               $this->archivedPage->getTextFromRow( (object)$row );
+       }
+
+       /**
+        * @covers PageArchive::getLastRevisionText
+        */
+       public function testGetLastRevisionText() {
+               $this->hideDeprecated( PageArchive::class . '::getLastRevisionText' );
+
+               $text = $this->archivedPage->getLastRevisionText();
+               $this->assertSame( 'Lorem Ipsum', $text );
+       }
+
+       /**
+        * @covers PageArchive::getLastRevisionId
+        */
+       public function testGetLastRevisionId() {
+               $id = $this->archivedPage->getLastRevisionId();
+               $this->assertSame( $this->ipRev->getId(), $id );
+       }
+
+       /**
+        * @covers PageArchive::isDeleted
+        */
+       public function testIsDeleted() {
+               $this->assertTrue( $this->archivedPage->isDeleted() );
+       }
+
+       /**
+        * @covers PageArchive::getRevision
+        */
+       public function testGetRevision() {
+               $rev = $this->archivedPage->getRevision( $this->ipRev->getTimestamp() );
+               $this->assertNotNull( $rev );
+               $this->assertSame( $this->pageId, $rev->getPage() );
+
+               $rev = $this->archivedPage->getRevision( '22991212115555' );
+               $this->assertNull( $rev );
+       }
+
+       /**
+        * @covers PageArchive::getRevision
+        */
+       public function testGetArchivedRevision() {
+               $rev = $this->archivedPage->getArchivedRevision( $this->ipRev->getId() );
+               $this->assertNotNull( $rev );
+               $this->assertSame( $this->ipRev->getTimestamp(), $rev->getTimestamp() );
+               $this->assertSame( $this->pageId, $rev->getPage() );
+
+               $rev = $this->archivedPage->getArchivedRevision( 632546 );
+               $this->assertNull( $rev );
+       }
+
+}
index 7e29c92..35bb1f0 100644 (file)
@@ -1029,6 +1029,13 @@ class LanguageTest extends LanguageClassesTestCase {
                                '2555',
                                'Thai year'
                        ],
+                       [
+                               'xkY',
+                               '19410101090705',
+                               '2484',
+                               '2484',
+                               'Thai year'
+                       ],
                        [
                                'xoY',
                                '20120102090705',