diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index 28d866942d4..52b97c0f20f 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -58,6 +58,7 @@ use MediaWiki\Page\MovePageFactory; use MediaWiki\Page\PageStore; use MediaWiki\Page\PageStoreFactory; use MediaWiki\Page\ParserOutputAccess; +use MediaWiki\Page\RollbackPageFactory; use MediaWiki\Page\WikiPageFactory; use MediaWiki\Parser\ParserCacheFactory; use MediaWiki\Permissions\GroupPermissionsLookup; @@ -1328,6 +1329,14 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'RevisionStoreFactory' ); } + /** + * @since 1.37 + * @return RollbackPageFactory + */ + public function getRollbackPageFactory() : RollbackPageFactory { + return $this->getService( 'RollbackPageFactory' ); + } + /** * @since 1.27 * @return SearchEngine diff --git a/includes/MovePage.php b/includes/MovePage.php index 2cfdde302e6..1897b1fe40b 100644 --- a/includes/MovePage.php +++ b/includes/MovePage.php @@ -117,7 +117,7 @@ class MovePage { private $userFactory; /** - * @internal For use by MovePageTest + * @internal For use by PageCommandFactory */ public const CONSTRUCTOR_OPTIONS = [ 'CategoryCollation', diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index a4fa64ec25d..7643e7790bf 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -91,6 +91,7 @@ use MediaWiki\Page\PageCommandFactory; use MediaWiki\Page\PageStore; use MediaWiki\Page\PageStoreFactory; use MediaWiki\Page\ParserOutputAccess; +use MediaWiki\Page\RollbackPageFactory; use MediaWiki\Page\WikiPageFactory; use MediaWiki\Parser\ParserCacheFactory; use MediaWiki\Permissions\GroupPermissionsLookup; @@ -1262,6 +1263,10 @@ return [ return $store; }, + 'RollbackPageFactory' => static function ( MediaWikiServices $services ) : RollbackPageFactory { + return $services->get( '_PageCommandFactory' ); + }, + 'SearchEngineConfig' => static function ( MediaWikiServices $services ) : SearchEngineConfig { // @todo This should not take a Config object, but it's not so easy to remove because it // exposes it in a getter, which is actually used. @@ -1702,17 +1707,20 @@ return [ '_PageCommandFactory' => static function ( MediaWikiServices $services ) : PageCommandFactory { return new PageCommandFactory( - new ServiceOptions( PageCommandFactory::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ), + $services->getMainConfig(), $services->getDBLoadBalancer(), $services->getNamespaceInfo(), $services->getWatchedItemStore(), $services->getRepoGroup(), + $services->getReadOnlyMode(), $services->getContentHandlerFactory(), $services->getRevisionStore(), $services->getSpamChecker(), + $services->getTitleFormatter(), $services->getHookContainer(), $services->getWikiPageFactory(), - $services->getUserFactory() + $services->getUserFactory(), + $services->getActorMigration() ); }, diff --git a/includes/actions/RollbackAction.php b/includes/actions/RollbackAction.php index 68dc21a514a..1072cdb6a78 100644 --- a/includes/actions/RollbackAction.php +++ b/includes/actions/RollbackAction.php @@ -114,25 +114,28 @@ class RollbackAction extends FormAction { ] ); } - $data = null; - $errors = $this->getWikiPage()->doRollback( - $from, - $request->getText( 'summary' ), - $request->getVal( 'token' ), - $request->getBool( 'bot' ), - $data, - $this->getContext()->getAuthority() - ); + // The revision has the user suppressed, so the rollback has empty 'from', + // so the check above would succeed in that case. + if ( !$revUser ) { + $revUser = $rev->getUser( RevisionRecord::RAW ); + } - if ( in_array( [ 'actionthrottledtext' ], $errors ) ) { + $rollbackResult = MediaWikiServices::getInstance() + ->getRollbackPageFactory() + ->newRollbackPage( $this->getWikiPage(), $this->getContext()->getAuthority(), $revUser ) + ->setSummary( $request->getText( 'summary' ) ) + ->markAsBot( $request->getVal( 'token' ) ) + ->rollbackIfAllowed(); + $data = $rollbackResult->getValue(); + + if ( $rollbackResult->hasMessage( 'actionthrottledtext' ) ) { throw new ThrottledError; } - if ( $this->hasRollbackRelatedErrors( $errors ) ) { + if ( $rollbackResult->hasMessage( 'alreadyrolled' ) || $rollbackResult->hasMessage( 'cantrollback' ) ) { $this->getOutput()->setPageTitle( $this->msg( 'rollbackfailed' ) ); - $errArray = $errors[0]; - $errMsg = array_shift( $errArray ); - $this->getOutput()->addWikiMsgArray( $errMsg, $errArray ); + $errArray = $rollbackResult->getErrors()[0]; + $this->getOutput()->addWikiMsgArray( $errArray['message'], $errArray['params'] ); if ( isset( $data['current-revision-record'] ) ) { /** @var RevisionRecord $current */ @@ -154,14 +157,14 @@ class RollbackAction extends FormAction { } # NOTE: Permission errors already handled by Action::checkExecute. - if ( $errors == [ [ 'readonlytext' ] ] ) { + if ( $rollbackResult->hasMessage( 'readonlytext' ) ) { throw new ReadOnlyError; } # XXX: Would be nice if ErrorPageError could take multiple errors, and/or a status object. # Right now, we only show the first error - foreach ( $errors as $error ) { - throw new ErrorPageError( 'rollbackfailed', $error[0], array_slice( $error, 1 ) ); + foreach ( $rollbackResult->getErrors() as $error ) { + throw new ErrorPageError( 'rollbackfailed', $error['message'], $error['params'] ); } /** @var RevisionRecord $current */ @@ -251,11 +254,4 @@ class RollbackAction extends FormAction { ] ]; } - - private function hasRollbackRelatedErrors( array $errors ) { - return isset( $errors[0][0] ) && - ( $errors[0][0] == 'alreadyrolled' || - $errors[0][0] == 'cantrollback' - ); - } } diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php index 4a649c6999d..75906bdfb61 100644 --- a/includes/api/ApiMain.php +++ b/includes/api/ApiMain.php @@ -120,7 +120,12 @@ class ApiMain extends ApiBase { 'WatchedItemStore', ] ], - 'rollback' => ApiRollback::class, + 'rollback' => [ + 'class' => ApiRollback::class, + 'services' => [ + 'RollbackPageFactory', + ] + ], 'delete' => ApiDelete::class, 'undelete' => ApiUndelete::class, 'protect' => ApiProtect::class, diff --git a/includes/api/ApiRollback.php b/includes/api/ApiRollback.php index 9ea1055fd8c..59c32f534ea 100644 --- a/includes/api/ApiRollback.php +++ b/includes/api/ApiRollback.php @@ -20,6 +20,7 @@ * @file */ +use MediaWiki\Page\RollbackPageFactory; use MediaWiki\ParamValidator\TypeDef\UserDef; use MediaWiki\User\UserIdentity; @@ -30,11 +31,19 @@ class ApiRollback extends ApiBase { use ApiWatchlistTrait; - public function __construct( ApiMain $mainModule, $moduleName, $modulePrefix = '' ) { - parent::__construct( $mainModule, $moduleName, $modulePrefix ); + /** @var RollbackPageFactory */ + private $rollbackPageFactory; + + public function __construct( + ApiMain $mainModule, + $moduleName, + RollbackPageFactory $rollbackPageFactory + ) { + parent::__construct( $mainModule, $moduleName ); $this->watchlistExpiryEnabled = $this->getConfig()->get( 'WatchlistExpiry' ); $this->watchlistMaxDuration = $this->getConfig()->get( 'WatchlistExpiryMaxDuration' ); + $this->rollbackPageFactory = $rollbackPageFactory; } /** @@ -54,12 +63,9 @@ class ApiRollback extends ApiBase { $params = $this->extractRequestParams(); $titleObj = $this->getRbTitle( $params ); - $pageObj = WikiPage::factory( $titleObj ); - $summary = $params['summary']; - $details = []; // If change tagging was requested, check that the user is allowed to tag, - // and the tags are valid + // and the tags are valid. TODO: move inside rollback command? if ( $params['tags'] ) { $tagStatus = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $this->getAuthority() ); if ( !$tagStatus->isOK() ) { @@ -76,18 +82,15 @@ class ApiRollback extends ApiBase { $trxProfiler->redefineExpectations( $trxLimits['PostSend-POST'], $fname ); } ); - $retval = $pageObj->doRollback( - $this->getRbUser( $params )->getName(), - $summary, - $params['token'], - $params['markbot'], - $details, - $this->getAuthority(), - $params['tags'] - ); + $rollbackResult = $this->rollbackPageFactory + ->newRollbackPage( $titleObj, $this->getAuthority(), $this->getRbUser( $params ) ) + ->setSummary( $params['summary'] ) + ->markAsBot( $params['markbot'] ) + ->setChangeTags( $params['tags'] ) + ->rollbackIfAllowed(); - if ( $retval ) { - $this->dieStatus( $this->errorArrayToStatus( $retval, $user ) ); + if ( !$rollbackResult->isGood() ) { + $this->dieStatus( $rollbackResult ); } $watch = $params['watchlist'] ?? 'preferences'; @@ -96,6 +99,7 @@ class ApiRollback extends ApiBase { // Watch pages $this->setWatch( $watch, $titleObj, $user, 'watchrollback', $watchlistExpiry ); + $details = $rollbackResult->getValue(); $currentRevisionRecord = $details['current-revision-record']; $targetRevisionRecord = $details['target-revision-record']; diff --git a/includes/installer/WebInstallerOptions.php b/includes/installer/WebInstallerOptions.php index 46de5065567..dde2531761b 100644 --- a/includes/installer/WebInstallerOptions.php +++ b/includes/installer/WebInstallerOptions.php @@ -221,6 +221,7 @@ class WebInstallerOptions extends WebInstallerPage { } } + // @phan-suppress-next-line SecurityCheck-XSS $text = wfMessage( 'config-extensions-requires' ) ->rawParams( $ext, $wgLang->commaList( $links ) ) ->escaped(); diff --git a/includes/page/PageCommandFactory.php b/includes/page/PageCommandFactory.php index 35fadc8efe4..6ffa382cdfc 100644 --- a/includes/page/PageCommandFactory.php +++ b/includes/page/PageCommandFactory.php @@ -22,6 +22,8 @@ namespace MediaWiki\Page; +use ActorMigration; +use Config; use ContentModelChange; use MediaWiki\Config\ServiceOptions; use MediaWiki\Content\IContentHandlerFactory; @@ -30,11 +32,14 @@ use MediaWiki\HookContainer\HookContainer; use MediaWiki\Permissions\Authority; use MediaWiki\Revision\RevisionStore; use MediaWiki\User\UserFactory; +use MediaWiki\User\UserIdentity; use MergeHistory; use MovePage; use NamespaceInfo; +use ReadOnlyMode; use RepoGroup; use Title; +use TitleFormatter; use WatchedItemStoreInterface; use Wikimedia\Rdbms\ILoadBalancer; use WikiPage; @@ -44,9 +49,15 @@ use WikiPage; * * @since 1.35 */ -class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFactory, MovePageFactory { - /** @var ServiceOptions */ - private $options; +class PageCommandFactory implements + ContentModelChangeFactory, + MergeHistoryFactory, + MovePageFactory, + RollbackPageFactory +{ + + /** @var Config */ + private $config; /** @var ILoadBalancer */ private $loadBalancer; @@ -60,6 +71,9 @@ class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFacto /** @var RepoGroup */ private $repoGroup; + /** @var ReadOnlyMode */ + private $readOnlyMode; + /** @var IContentHandlerFactory */ private $contentHandlerFactory; @@ -69,6 +83,9 @@ class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFacto /** @var SpamChecker */ private $spamChecker; + /** @var TitleFormatter */ + private $titleFormatter; + /** @var HookContainer */ private $hookContainer; @@ -78,40 +95,39 @@ class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFacto /** @var UserFactory */ private $userFactory; - /** - * @internal For use by ServiceWiring - */ - public const CONSTRUCTOR_OPTIONS = [ - 'CategoryCollation', - 'MaximumMovedPages', - ]; + /** @var ActorMigration */ + private $actorMigration; public function __construct( - ServiceOptions $options, + Config $config, ILoadBalancer $loadBalancer, NamespaceInfo $namespaceInfo, WatchedItemStoreInterface $watchedItemStore, RepoGroup $repoGroup, + ReadOnlyMode $readOnlyMode, IContentHandlerFactory $contentHandlerFactory, RevisionStore $revisionStore, SpamChecker $spamChecker, + TitleFormatter $titleFormatter, HookContainer $hookContainer, WikiPageFactory $wikiPageFactory, - UserFactory $userFactory + UserFactory $userFactory, + ActorMigration $actorMigration ) { - $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); - - $this->options = $options; + $this->config = $config; $this->loadBalancer = $loadBalancer; $this->namespaceInfo = $namespaceInfo; $this->watchedItemStore = $watchedItemStore; $this->repoGroup = $repoGroup; + $this->readOnlyMode = $readOnlyMode; $this->contentHandlerFactory = $contentHandlerFactory; $this->revisionStore = $revisionStore; $this->spamChecker = $spamChecker; + $this->titleFormatter = $titleFormatter; $this->hookContainer = $hookContainer; $this->wikiPageFactory = $wikiPageFactory; $this->userFactory = $userFactory; + $this->actorMigration = $actorMigration; } /** @@ -175,7 +191,7 @@ class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFacto return new MovePage( $from, $to, - $this->options, + new ServiceOptions( MovePage::CONSTRUCTOR_OPTIONS, $this->config ), $this->loadBalancer, $this->namespaceInfo, $this->watchedItemStore, @@ -188,4 +204,33 @@ class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFacto $this->userFactory ); } + + /** + * Create a new command instance for page rollback. + * + * @param PageIdentity $page + * @param Authority $performer + * @param UserIdentity $byUser + * @return RollbackPage + */ + public function newRollbackPage( + PageIdentity $page, + Authority $performer, + UserIdentity $byUser + ) : RollbackPage { + return new RollbackPage( + new ServiceOptions( RollbackPage::CONSTRUCTOR_OPTIONS, $this->config ), + $this->loadBalancer, + $this->userFactory, + $this->readOnlyMode, + $this->revisionStore, + $this->titleFormatter, + $this->hookContainer, + $this->wikiPageFactory, + $this->actorMigration, + $page, + $performer, + $byUser + ); + } } diff --git a/includes/page/RollbackPage.php b/includes/page/RollbackPage.php new file mode 100644 index 00000000000..f9992b17eb5 --- /dev/null +++ b/includes/page/RollbackPage.php @@ -0,0 +1,518 @@ +assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); + $this->options = $options; + $this->loadBalancer = $loadBalancer; + $this->userFactory = $userFactory; + $this->readOnlyMode = $readOnlyMode; + $this->revisionStore = $revisionStore; + $this->titleFormatter = $titleFormatter; + $this->hookContainer = $hookContainer; + $this->hookRunner = new HookRunner( $hookContainer ); + $this->wikiPageFactory = $wikiPageFactory; + $this->actorMigration = $actorMigration; + + $this->page = $page; + $this->performer = $performer; + $this->byUser = $byUser; + } + + /** + * Set custom edit summary. + * + * @param string|null $summary + * @return $this + */ + public function setSummary( ?string $summary ): self { + $this->summary = $summary ?: ''; + return $this; + } + + /** + * Mark all reverted edits as bot. + * + * @param bool|null $bot + * @return $this + */ + public function markAsBot( ?bool $bot ): self { + if ( $bot && $this->performer->isAllowedAny( 'markbotedits', 'bot' ) ) { + $this->bot = true; + } elseif ( !$bot ) { + $this->bot = false; + } + return $this; + } + + /** + * Change tags to apply to the rollback. + * + * @note Callers are responsible for permission checks (with ChangeTags::canAddTagsAccompanyingChange) + * + * @param string[]|null $tags + * @return $this + */ + public function setChangeTags( ?array $tags ): self { + $this->tags = $tags ?: []; + return $this; + } + + /** + * Authorize the rollback. + * + * @return PermissionStatus + */ + public function authorizeRollback(): PermissionStatus { + $permissionStatus = PermissionStatus::newEmpty(); + $this->performer->authorizeWrite( 'edit', $this->page, $permissionStatus ); + $this->performer->authorizeWrite( 'rollback', $this->page, $permissionStatus ); + + if ( $this->readOnlyMode->isReadOnly() ) { + $permissionStatus->fatal( 'readonlytext' ); + } + + $user = $this->userFactory->newFromAuthority( $this->performer ); + if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) { + $permissionStatus->fatal( 'actionthrottledtext' ); + } + return $permissionStatus; + } + + /** + * Rollback the most recent consecutive set of edits to a page + * from the same user; fails if there are no eligible edits to + * roll back to, e.g. user is the sole contributor. This function + * performs permissions checks and executes ::rollback. + * + * @return StatusValue see ::rollback for return value documentation. + * In case the rollback is not allowed, PermissionStatus is returned. + */ + public function rollbackIfAllowed(): StatusValue { + $permissionStatus = $this->authorizeRollback(); + if ( !$permissionStatus->isGood() ) { + return $permissionStatus; + } + return $this->rollback(); + } + + /** + * Backend implementation of rollbackIfAllowed(). + * + * @note This function does NOT check ANY permissions, it just commits the + * rollback to the DB. Therefore, you should only call this function directly + * if you want to use custom permissions checks. If you don't, use + * ::rollbackIfAllowed() instead. + * + * @return StatusValue On success, wrapping the array with the following keys: + * 'summary' - rollback edit summary + * 'current-revision-record' - revision record that was current before rollback + * 'target-revision-record' - revision record we are rolling back to + * 'newid' => the id of the rollback revision + * 'tags' => the tags applied to the rollback + */ + public function rollback() { + // Begin revision creation cycle by creating a PageUpdater. + // If the page is changed concurrently after grabParentRevision(), the rollback will fail. + // TODO: move PageUpdater to PageStore or PageUpdaterFactory or something? + $updater = $this->wikiPageFactory->newFromTitle( $this->page )->newPageUpdater( $this->performer ); + $currentRevision = $updater->grabParentRevision(); + + if ( !$currentRevision ) { + // Something wrong... no page? + return StatusValue::newFatal( 'notanarticle' ); + } + + $currentEditor = $currentRevision->getUser( RevisionRecord::RAW ); + $currentEditorForPublic = $currentRevision->getUser( RevisionRecord::FOR_PUBLIC ); + // User name given should match up with the top revision. + if ( !$this->byUser->equals( $currentEditor ) ) { + $result = StatusValue::newGood( [ + 'current-revision-record' => $currentRevision + ] ); + $result->fatal( + 'alreadyrolled', + htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ), + htmlspecialchars( $this->byUser->getName() ), + htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' ) + ); + return $result; + } + + $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER ); + + // TODO: move this query to RevisionSelectQueryBuilder when it's available + // Get the last edit not by this person... + // Note: these may not be public values + $actorWhere = $this->actorMigration->getWhere( $dbw, 'rev_user', $currentEditor ); + $targetRevisionRow = $dbw->selectRow( + [ 'revision' ] + $actorWhere['tables'], + [ 'rev_id', 'rev_timestamp', 'rev_deleted' ], + [ + 'rev_page' => $currentRevision->getPageId(), + 'NOT(' . $actorWhere['conds'] . ')', + ], + __METHOD__, + [ + 'USE INDEX' => [ 'revision' => 'page_timestamp' ], + 'ORDER BY' => [ 'rev_timestamp DESC', 'rev_id DESC' ] + ], + $actorWhere['joins'] + ); + + if ( $targetRevisionRow === false ) { + // No one else ever edited this page + return StatusValue::newFatal( 'cantrollback' ); + } elseif ( $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_TEXT + || $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_USER + ) { + // Only admins can see this text + return StatusValue::newFatal( 'notvisiblerev' ); + } + + // Generate the edit summary if necessary + $targetRevision = $this->revisionStore + ->getRevisionById( $targetRevisionRow->rev_id, RevisionStore::READ_LATEST ); + + // Save + $flags = EDIT_UPDATE | EDIT_INTERNAL; + + if ( $this->performer->isAllowed( 'minoredit' ) ) { + $flags |= EDIT_MINOR; + } + + if ( $this->bot ) { + $flags |= EDIT_FORCE_BOT; + } + + // TODO: MCR: also log model changes in other slots, in case that becomes possible! + $currentContent = $currentRevision->getContent( SlotRecord::MAIN ); + $targetContent = $targetRevision->getContent( SlotRecord::MAIN ); + $changingContentModel = $targetContent->getModel() !== $currentContent->getModel(); + + // Build rollback revision: + // Restore old content + // TODO: MCR: test this once we can store multiple slots + foreach ( $targetRevision->getSlots()->getSlots() as $slot ) { + $updater->inheritSlot( $slot ); + } + + // Remove extra slots + // TODO: MCR: test this once we can store multiple slots + foreach ( $currentRevision->getSlotRoles() as $role ) { + if ( !$targetRevision->hasSlot( $role ) ) { + $updater->removeSlot( $role ); + } + } + + $updater->setOriginalRevisionId( $targetRevision->getId() ); + $oldestRevertedRevision = $this->revisionStore->getNextRevision( + $targetRevision, + RevisionStore::READ_LATEST + ); + if ( $oldestRevertedRevision !== null ) { + $updater->markAsRevert( + EditResult::REVERT_ROLLBACK, + $oldestRevertedRevision->getId(), + $currentRevision->getId() + ); + } + + // TODO: this logic should not be in the storage layer, it's here for compatibility + // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same + // place the 'bot' right is handled, which is currently in EditPage::attemptSave. + if ( $this->options->get( 'UseRCPatrol' ) && + $this->performer->authorizeWrite( 'autopatrol', $this->page ) + ) { + $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED ); + } + + $summary = $this->getSummary( $currentRevision, $targetRevision ); + + // Actually store the rollback + $rev = $updater->saveRevision( + CommentStoreComment::newUnsavedComment( $summary ), + $flags + ); + + // This is done even on edit failure to have patrolling in that case (T64157). + $this->updateRecentChange( $dbw, $currentRevision, $targetRevision ); + + if ( !$updater->wasSuccessful() ) { + return $updater->getStatus(); + } + + // Report if the edit was not created because it did not change the content. + if ( $updater->isUnchanged() ) { + $result = StatusValue::newGood( [ + 'current-revision-record' => $currentRevision + ] ); + $result->fatal( + 'alreadyrolled', + htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ), + htmlspecialchars( $this->byUser->getName() ), + htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' ) + ); + return $result; + } + + if ( $changingContentModel ) { + // If the content model changed during the rollback, + // make sure it gets logged to Special:Log/contentmodel + $log = new ManualLogEntry( 'contentmodel', 'change' ); + $log->setPerformer( $this->performer->getUser() ); + $log->setTarget( new TitleValue( $this->page->getNamespace(), $this->page->getDBkey() ) ); + $log->setComment( $summary ); + $log->setParameters( [ + '4::oldmodel' => $currentContent->getModel(), + '5::newmodel' => $targetContent->getModel(), + ] ); + + $logId = $log->insert( $dbw ); + $log->publish( $logId ); + } + + // Hook is hard deprecated since 1.35 + $user = $this->userFactory->newFromAuthority( $this->performer ); + $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page ); + if ( $this->hookContainer->isRegistered( 'ArticleRollbackComplete' ) ) { + // Only create the Revision objects if needed + $legacyCurrent = new Revision( $currentRevision ); + $legacyTarget = new Revision( $targetRevision ); + $this->hookRunner->onArticleRollbackComplete( $wikiPage, $user, + $legacyTarget, $legacyCurrent ); + } + + $this->hookRunner->onRollbackComplete( $wikiPage, $user, $targetRevision, $currentRevision ); + + return StatusValue::newGood( [ + 'summary' => $summary, + 'current-revision-record' => $currentRevision, + 'target-revision-record' => $targetRevision, + 'newid' => $rev->getId(), + 'tags' => array_merge( $this->tags, $updater->getEditResult()->getRevertTags() ) + ] ); + } + + /** + * Set patrolling and bot flag on the edits, which gets rolled back. + * + * @param IDatabase $dbw + * @param RevisionRecord $current + * @param RevisionRecord $target + */ + private function updateRecentChange( + IDatabase $dbw, + RevisionRecord $current, + RevisionRecord $target + ) { + $set = []; + if ( $this->bot ) { + // Mark all reverted edits as bot + $set['rc_bot'] = 1; + } + + if ( $this->options->get( 'UseRCPatrol' ) ) { + // Mark all reverted edits as patrolled + $set['rc_patrolled'] = RecentChange::PRC_AUTOPATROLLED; + } + + if ( $set ) { + $actorWhere = $this->actorMigration->getWhere( + $dbw, + 'rc_user', + $current->getUser( RevisionRecord::RAW ), + false + ); + $dbw->update( + 'recentchanges', + $set, + [ /* WHERE */ + 'rc_cur_id' => $current->getPageId(), + 'rc_timestamp > ' . $dbw->addQuotes( $dbw->timestamp( $target->getTimestamp() ) ), + $actorWhere['conds'], // No tables/joins are needed for rc_user + ], + __METHOD__ + ); + } + } + + /** + * Generate and format summary for the rollback. + * + * @param RevisionRecord $current + * @param RevisionRecord $target + * @return string + */ + private function getSummary( RevisionRecord $current, RevisionRecord $target ): string { + $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC ); + if ( !$this->summary ) { + if ( !$currentEditorForPublic ) { // no public user name + $summary = MessageValue::new( 'revertpage-nouser' ); + } elseif ( $this->options->get( 'DisableAnonTalk' ) && !$currentEditorForPublic->isRegistered() ) { + $summary = MessageValue::new( 'revertpage-anon' ); + } else { + $summary = MessageValue::new( 'revertpage' ); + } + } else { + $summary = $this->summary; + } + + $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC ); + // Allow the custom summary to use the same args as the default message + $args = [ + $targetEditorForPublic ? $targetEditorForPublic->getName() : null, + $currentEditorForPublic ? $currentEditorForPublic->getName() : null, + $target->getId(), + Message::dateTimeParam( $target->getTimestamp() ), + $current->getId(), + Message::dateTimeParam( $current->getTimestamp() ), + ]; + if ( $summary instanceof MessageValue ) { + $summary = ( new Converter() )->convertMessageValue( $summary ); + $summary = $summary->params( $args )->inContentLanguage()->text(); + } else { + $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain(); + } + + // Trim spaces on user supplied text + return trim( $summary ); + } +} diff --git a/includes/page/RollbackPageFactory.php b/includes/page/RollbackPageFactory.php new file mode 100644 index 00000000000..c9bcd108fff --- /dev/null +++ b/includes/page/RollbackPageFactory.php @@ -0,0 +1,47 @@ +mTitle ) ); } - /** - * Roll back the most recent consecutive set of edits to a page - * from the same user; fails if there are no eligible edits to - * roll back to, e.g. user is the sole contributor. This function - * performs permissions checks on $user, then calls commitRollback() - * to do the dirty work - * - * @internal since 1.35 - * - * @todo Separate the business/permission stuff out from backend code - * @todo Remove $token parameter. Already verified by RollbackAction and ApiRollback. - * - * @param string $fromP Name of the user whose edits to rollback. - * @param string $summary Custom summary. Set to default summary if empty. - * @param string $token Rollback token. - * @param bool $bot If true, mark all reverted edits as bot. - * - * @param array &$resultDetails Array contains result-specific array of additional values - * 'alreadyrolled' : 'current' (rev) - * success : 'summary' (str), 'current' (rev), 'target' (rev) - * - * @param Authority $performer doing the rollback - * @param array|null $tags Change tags to apply to the rollback - * Callers are responsible for permission checks - * (with ChangeTags::canAddTagsAccompanyingChange) - * - * @return array[] Array of errors, each error formatted as - * [ messagekey, param1, param2, ... ]. - * On success, the array is empty. This array can also be passed to - * OutputPage::showPermissionsErrorPage(). - */ - public function doRollback( - $fromP, $summary, $token, $bot, &$resultDetails, Authority $performer, $tags = null - ) { - $this->assertProperPage(); - - $resultDetails = null; - - // Check permissions - $permissionStatus = PermissionStatus::newEmpty(); - $performer->authorizeWrite( 'edit', $this->getTitle(), $permissionStatus ); - $performer->authorizeWrite( 'rollback', $this->getTitle(), $permissionStatus ); - - $user = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $performer ); - if ( !$user->matchEditToken( $token, 'rollback' ) ) { - $permissionStatus->fatal( 'sessionfailure' ); - } - - if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) { - $permissionStatus->fatal( 'actionthrottledtext' ); - } - - // If there were errors, bail out now - if ( !$permissionStatus->isGood() ) { - return $permissionStatus->toLegacyErrorArray(); - } - - return $this->commitRollback( $fromP, $summary, $bot, $resultDetails, $performer, $tags ); - } - - /** - * Backend implementation of doRollback(), please refer there for parameter - * and return value documentation - * - * @internal since 1.35 - * - * NOTE: This function does NOT check ANY permissions, it just commits the - * rollback to the DB. Therefore, you should only call this function direct- - * ly if you want to use custom permissions checks. If you don't, use - * doRollback() instead. - * - * @param string $fromP Name of the user whose edits to rollback. - * @param string $summary Custom summary. Set to default summary if empty. - * @param bool $bot If true, mark all reverted edits as bot. - * @param array &$resultDetails Contains result-specific array of additional values - * @param Authority $performer The user performing the rollback - * @param array|null $tags Change tags to apply to the rollback - * Callers are responsible for permission checks - * (with ChangeTags::canAddTagsAccompanyingChange) - * - * @return array An array of error messages, as returned by Status::getErrorsArray() - */ - public function commitRollback( $fromP, $summary, $bot, - &$resultDetails, Authority $performer, $tags = null - ) { - global $wgUseRCPatrol, $wgDisableAnonTalk; - - $dbw = wfGetDB( DB_MASTER ); - - if ( wfReadOnly() ) { - return [ [ 'readonlytext' ] ]; - } - - // Begin revision creation cycle by creating a PageUpdater. - // If the page is changed concurrently after grabParentRevision(), the rollback will fail. - $user = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $performer ); - $updater = $this->newPageUpdater( $user ); - $current = $updater->grabParentRevision(); - - if ( $current === null ) { - // Something wrong... no page? - return [ [ 'notanarticle' ] ]; - } - - $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC ); - $legacyCurrentCallback = static function () use ( $current ) { - // Only created when needed - return new Revision( $current ); - }; - $from = str_replace( '_', ' ', $fromP ); - - // User name given should match up with the top revision. - // If the revision's user is not visible, then $from should be empty. - if ( $from !== ( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' ) ) { - $resultDetails = new DeprecatablePropertyArray( - [ - 'current' => $legacyCurrentCallback, - 'current-revision-record' => $current, - ], - [ 'current' => '1.35' ], - __METHOD__ - ); - return [ [ 'alreadyrolled', - htmlspecialchars( $this->mTitle->getPrefixedText() ), - htmlspecialchars( $fromP ), - htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' ) - ] ]; - } - - // Get the last edit not by this person... - // Note: these may not be public values - $actorWhere = ActorMigration::newMigration()->getWhere( - $dbw, - 'rev_user', - $current->getUser( RevisionRecord::RAW ) - ); - - $s = $dbw->selectRow( - [ 'revision' ] + $actorWhere['tables'], - [ 'rev_id', 'rev_timestamp', 'rev_deleted' ], - [ - 'rev_page' => $current->getPageId(), - 'NOT(' . $actorWhere['conds'] . ')', - ], - __METHOD__, - [ - 'USE INDEX' => [ 'revision' => 'page_timestamp' ], - 'ORDER BY' => [ 'rev_timestamp DESC', 'rev_id DESC' ] - ], - $actorWhere['joins'] - ); - if ( $s === false ) { - // No one else ever edited this page - return [ [ 'cantrollback' ] ]; - } elseif ( $s->rev_deleted & RevisionRecord::DELETED_TEXT - || $s->rev_deleted & RevisionRecord::DELETED_USER - ) { - // Only admins can see this text - return [ [ 'notvisiblerev' ] ]; - } - - // Generate the edit summary if necessary - $target = $this->getRevisionStore()->getRevisionById( - $s->rev_id, - RevisionStore::READ_LATEST - ); - if ( empty( $summary ) ) { - if ( !$currentEditorForPublic ) { // no public user name - $summary = wfMessage( 'revertpage-nouser' ); - } elseif ( $wgDisableAnonTalk && $current->getUser() === 0 ) { - $summary = wfMessage( 'revertpage-anon' ); - } else { - $summary = wfMessage( 'revertpage' ); - } - } - $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC ); - - // Allow the custom summary to use the same args as the default message - $contLang = MediaWikiServices::getInstance()->getContentLanguage(); - $args = [ - $targetEditorForPublic ? $targetEditorForPublic->getName() : null, - $currentEditorForPublic ? $currentEditorForPublic->getName() : null, - $s->rev_id, - $contLang->timeanddate( MWTimestamp::convert( TS_MW, $s->rev_timestamp ) ), - $current->getId(), - $contLang->timeanddate( $current->getTimestamp() ) - ]; - if ( $summary instanceof Message ) { - $summary = $summary->params( $args )->inContentLanguage()->text(); - } else { - $summary = wfMsgReplaceArgs( $summary, $args ); - } - - // Trim spaces on user supplied text - $summary = trim( $summary ); - - // Save - $flags = EDIT_UPDATE | EDIT_INTERNAL; - - if ( $performer->isAllowed( 'minoredit' ) ) { - $flags |= EDIT_MINOR; - } - - if ( $bot && ( $performer->isAllowedAny( 'markbotedits', 'bot' ) ) ) { - $flags |= EDIT_FORCE_BOT; - } - - // TODO: MCR: also log model changes in other slots, in case that becomes possible! - $currentContent = $current->getContent( SlotRecord::MAIN ); - $targetContent = $target->getContent( SlotRecord::MAIN ); - $changingContentModel = $targetContent->getModel() !== $currentContent->getModel(); - - // Build rollback revision: - // Restore old content - // TODO: MCR: test this once we can store multiple slots - foreach ( $target->getSlots()->getSlots() as $slot ) { - $updater->inheritSlot( $slot ); - } - - // Remove extra slots - // TODO: MCR: test this once we can store multiple slots - foreach ( $current->getSlotRoles() as $role ) { - if ( !$target->hasSlot( $role ) ) { - $updater->removeSlot( $role ); - } - } - - $updater->setOriginalRevisionId( $target->getId() ); - $oldestRevertedRevision = $this->getRevisionStore()->getNextRevision( - $target, - RevisionStore::READ_LATEST - ); - if ( $oldestRevertedRevision !== null ) { - $updater->markAsRevert( - EditResult::REVERT_ROLLBACK, - $oldestRevertedRevision->getId(), - $current->getId() - ); - } - - // TODO: this logic should not be in the storage layer, it's here for compatibility - // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same - // place the 'bot' right is handled, which is currently in EditPage::attemptSave. - - if ( $wgUseRCPatrol && $performer->authorizeWrite( 'autopatrol', $this->getTitle() ) ) { - $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED ); - } - - // Actually store the rollback - $rev = $updater->saveRevision( - CommentStoreComment::newUnsavedComment( $summary ), - $flags - ); - - // Set patrolling and bot flag on the edits, which gets rollbacked. - // This is done even on edit failure to have patrolling in that case (T64157). - $set = []; - if ( $bot && $performer->isAllowed( 'markbotedits' ) ) { - // Mark all reverted edits as bot - $set['rc_bot'] = 1; - } - - if ( $wgUseRCPatrol ) { - // Mark all reverted edits as patrolled - $set['rc_patrolled'] = RecentChange::PRC_AUTOPATROLLED; - } - - if ( count( $set ) ) { - $actorWhere = ActorMigration::newMigration()->getWhere( - $dbw, - 'rc_user', - $current->getUser( RevisionRecord::RAW ), - false - ); - $dbw->update( 'recentchanges', $set, - [ /* WHERE */ - 'rc_cur_id' => $current->getPageId(), - 'rc_timestamp > ' . $dbw->addQuotes( $s->rev_timestamp ), - $actorWhere['conds'], // No tables/joins are needed for rc_user - ], - __METHOD__ - ); - } - - if ( !$updater->wasSuccessful() ) { - return $updater->getStatus()->getErrorsArray(); - } - - // Report if the edit was not created because it did not change the content. - if ( $updater->isUnchanged() ) { - $resultDetails = new DeprecatablePropertyArray( - [ - 'current' => $legacyCurrentCallback, - 'current-revision-record' => $current, - ], - [ 'current' => '1.35' ], - __METHOD__ - ); - return [ [ 'alreadyrolled', - htmlspecialchars( $this->mTitle->getPrefixedText() ), - htmlspecialchars( $fromP ), - htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' ) - ] ]; - } - - if ( $changingContentModel ) { - // If the content model changed during the rollback, - // make sure it gets logged to Special:Log/contentmodel - $log = new ManualLogEntry( 'contentmodel', 'change' ); - $log->setPerformer( $performer->getUser() ); - $log->setTarget( $this->mTitle ); - $log->setComment( $summary ); - $log->setParameters( [ - '4::oldmodel' => $currentContent->getModel(), - '5::newmodel' => $targetContent->getModel(), - ] ); - - $logId = $log->insert( $dbw ); - $log->publish( $logId ); - } - - $revId = $rev->getId(); - - // Hook is hard deprecated since 1.35 - if ( $this->getHookContainer()->isRegistered( 'ArticleRollbackComplete' ) ) { - // Only create the Revision objects if needed - $legacyCurrent = new Revision( $current ); - $legacyTarget = new Revision( $target ); - $this->getHookRunner()->onArticleRollbackComplete( $this, $user, - $legacyTarget, $legacyCurrent ); - } - - $this->getHookRunner()->onRollbackComplete( $this, $user, $target, $current ); - - $legacyTargetCallback = static function () use ( $target ) { - // Only create the Revision object if needed - return new Revision( $target ); - }; - - $tags = array_merge( - $tags ?: [], - $updater->getEditResult()->getRevertTags() - ); - - $resultDetails = new DeprecatablePropertyArray( - [ - 'summary' => $summary, - 'current' => $legacyCurrentCallback, - 'current-revision-record' => $current, - 'target' => $legacyTargetCallback, - 'target-revision-record' => $target, - 'newid' => $revId, - 'tags' => $tags - ], - [ 'current' => '1.35', 'target' => '1.35' ], - __METHOD__ - ); - - // TODO: make this return a Status object and wrap $resultDetails in that. - return []; - } - /** * The onArticle*() functions are supposed to be a kind of hooks * which should be called whenever any of the specified actions diff --git a/maintenance/rollbackEdits.php b/maintenance/rollbackEdits.php index 048a978c96d..87b0fab636c 100644 --- a/maintenance/rollbackEdits.php +++ b/maintenance/rollbackEdits.php @@ -58,7 +58,6 @@ class RollbackEdits extends Maintenance { $bot = $this->hasOption( 'bot' ); $summary = $this->getOption( 'summary', $this->mSelf . ' mass rollback' ); $titles = []; - $results = []; if ( $this->hasOption( 'titles' ) ) { foreach ( explode( '|', $this->getOption( 'titles' ) ) as $title ) { $t = Title::newFromText( $title ); @@ -81,10 +80,17 @@ class RollbackEdits extends Maintenance { $doer = User::newSystemUser( 'Maintenance script', [ 'steal' => true ] ); $wikiPageFactory = MediaWikiServices::getInstance()->getWikiPageFactory(); + $rollbackPageFactory = MediaWikiServices::getInstance() + ->getRollbackPageFactory(); foreach ( $titles as $t ) { $page = $wikiPageFactory->newFromTitle( $t ); $this->output( 'Processing ' . $t->getPrefixedText() . '... ' ); - if ( !$page->commitRollback( $user, $summary, $bot, $results, $doer ) ) { + $rollbackResult = $rollbackPageFactory + ->newRollbackPage( $page, $doer, $user ) + ->markAsBot( $bot ) + ->setSummary( $summary ) + ->rollback(); + if ( $rollbackResult->isGood() ) { $this->output( "Done!\n" ); } else { $this->output( "Failed!\n" ); diff --git a/tests/phpunit/MediaWikiIntegrationTestCase.php b/tests/phpunit/MediaWikiIntegrationTestCase.php index a9bf92f6782..89377ca0d1e 100644 --- a/tests/phpunit/MediaWikiIntegrationTestCase.php +++ b/tests/phpunit/MediaWikiIntegrationTestCase.php @@ -6,6 +6,7 @@ use MediaWiki\Logger\LegacySpi; use MediaWiki\Logger\LogCapturingSpi; use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; +use MediaWiki\Permissions\Authority; use MediaWiki\Revision\RevisionRecord; use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestResult; @@ -2386,7 +2387,7 @@ abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase { * @param string|Content $content the new content of the page * @param string $summary Optional summary string for the revision * @param int $defaultNs Optional namespace id - * @param User|null $user If null, static::getTestUser()->getUser() is used. + * @param Authority|null $performer If null, static::getTestUser()->getUser() is used. * @return Status Object as returned by WikiPage::doEditContent() * @throws MWException If this test cases's needsDB() method doesn't return true. * Test cases can use "@group Database" to enable database test support, @@ -2398,7 +2399,7 @@ abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase { $content, $summary = '', $defaultNs = NS_MAIN, - User $user = null + Authority $performer = null ) { if ( !$this->needsDB() ) { throw new MWException( 'When testing with pages, the test cases\'s needsDB()' . @@ -2415,8 +2416,8 @@ abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase { $page = WikiPage::factory( $title ); } - if ( $user === null ) { - $user = static::getTestUser()->getUser(); + if ( $performer === null ) { + $performer = static::getTestUser()->getUser(); } if ( is_string( $content ) ) { @@ -2428,7 +2429,7 @@ abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase { $summary, 0, false, - $user + $performer ); } diff --git a/tests/phpunit/includes/MovePageTest.php b/tests/phpunit/includes/MovePageTest.php index b68fa70c885..9c82690293e 100644 --- a/tests/phpunit/includes/MovePageTest.php +++ b/tests/phpunit/includes/MovePageTest.php @@ -3,7 +3,6 @@ use MediaWiki\Config\ServiceOptions; use MediaWiki\Interwiki\InterwikiLookup; use MediaWiki\MediaWikiServices; -use MediaWiki\Page\PageCommandFactory; use MediaWiki\Revision\SlotRecord; use Wikimedia\Rdbms\IDatabase; use Wikimedia\Rdbms\LoadBalancer; @@ -80,7 +79,7 @@ class MovePageTest extends MediaWikiIntegrationTestCase { $old, $new, new ServiceOptions( - PageCommandFactory::CONSTRUCTOR_OPTIONS, + MovePage::CONSTRUCTOR_OPTIONS, $params['options'] ?? [], [ 'CategoryCollation' => 'uppercase', diff --git a/tests/phpunit/includes/page/WikiPageDbTest.php b/tests/phpunit/includes/page/WikiPageDbTest.php index 64236362c71..9d74cec0217 100644 --- a/tests/phpunit/includes/page/WikiPageDbTest.php +++ b/tests/phpunit/includes/page/WikiPageDbTest.php @@ -1378,229 +1378,6 @@ more stuff ); } - /** - * @covers WikiPage::doRollback - * @covers WikiPage::commitRollback - */ - public function testDoRollback() { - $this->hideDeprecated( 'Revision::countByPageId' ); - $this->hideDeprecated( 'Revision::getUserText' ); - $this->hideDeprecated( 'Revision::__construct' ); - $this->hideDeprecated( 'Revision::getRevisionRecord' ); - $this->hideDeprecated( "MediaWiki\Storage\PageUpdater::doCreate status get 'revision'" ); - $this->hideDeprecated( "MediaWiki\Storage\PageUpdater::doModify status get 'revision'" ); - - $admin = $this->getTestSysop()->getUser(); - $user1 = $this->getTestUser()->getUser(); - // Use the confirmed group for user2 to make sure the user is different - $user2 = $this->getTestUser( [ 'confirmed' ] )->getUser(); - - // make sure we can test autopatrolling - $this->setMwGlobals( 'wgUseRCPatrol', true ); - - // TODO: MCR: test rollback of multiple slots! - $page = $this->newPage( __METHOD__ ); - - // Make some edits - $text = "one"; - $status1 = $page->doUserEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), - $admin, "section one", EDIT_NEW ); - - $text .= "\n\ntwo"; - $status2 = $page->doUserEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), - $user1, "adding section two" ); - - $text .= "\n\nthree"; - $status3 = $page->doUserEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), - $user2, "adding section three" ); - - /** @var Revision $rev1 */ - /** @var Revision $rev2 */ - /** @var Revision $rev3 */ - $rev1 = $status1->getValue()['revision']; - $rev2 = $status2->getValue()['revision']; - $rev3 = $status3->getValue()['revision']; - - /** - * We are having issues with doRollback spuriously failing. Apparently - * the last revision somehow goes missing or not committed under some - * circumstances. So, make sure the revisions have the correct usernames. - */ - $this->assertEquals( 3, Revision::countByPageId( wfGetDB( DB_REPLICA ), $page->getId() ) ); - $this->assertEquals( $admin->getName(), $rev1->getUserText() ); - $this->assertEquals( $user1->getName(), $rev2->getUserText() ); - $this->assertEquals( $user2->getName(), $rev3->getUserText() ); - - // Now, try the actual rollback - $token = $admin->getEditToken( 'rollback' ); - $rollbackErrors = $page->doRollback( - $user2->getName(), - "testing rollback", - $token, - false, - $resultDetails, - $admin - ); - - if ( $rollbackErrors ) { - $this->fail( - "Rollback failed:\n" . - print_r( $rollbackErrors, true ) . ";\n" . - print_r( $resultDetails, true ) - ); - } - - $page = new WikiPage( $page->getTitle() ); - $this->assertEquals( - $rev2->getRevisionRecord()->getSha1(), - $page->getRevisionRecord()->getSha1(), - "rollback did not revert to the correct revision" ); - $this->assertEquals( "one\n\ntwo", $page->getContent()->getText() ); - - $rc = MediaWikiServices::getInstance()->getRevisionStore()->getRecentChange( - $page->getRevisionRecord() - ); - - $this->assertNotNull( $rc, 'RecentChanges entry' ); - $this->assertEquals( - RecentChange::PRC_AUTOPATROLLED, - $rc->getAttribute( 'rc_patrolled' ), - 'rc_patrolled' - ); - - // TODO: MCR: assert origin once we write slot data - // $mainSlot = $page->getRevision()->getRevisionRecord()->getSlot( SlotRecord::MAIN ); - // $this->assertTrue( $mainSlot->isInherited(), 'isInherited' ); - // $this->assertSame( $rev2->getId(), $mainSlot->getOrigin(), 'getOrigin' ); - } - - /** - * @covers WikiPage::doRollback - * @covers WikiPage::commitRollback - */ - public function testDoRollbackFailureSameContent() { - $this->hideDeprecated( 'Revision::getSha1' ); - $this->hideDeprecated( 'Revision::__construct' ); - $this->hideDeprecated( 'WikiPage::getRevision' ); - - $admin = $this->getTestSysop()->getUser(); - - $text = "one"; - $page = $this->newPage( __METHOD__ ); - $page->doUserEditContent( - ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), - $admin, - "section one", - EDIT_NEW - ); - $rev1 = $page->getRevision(); - - $user1 = $this->getTestUser( [ 'sysop' ] )->getUser(); - $text .= "\n\ntwo"; - $page = new WikiPage( $page->getTitle() ); - $page->doUserEditContent( - ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), - $user1, - "adding section two" - ); - - # now, do a the rollback from the same user was doing the edit before - $resultDetails = []; - $token = $user1->getEditToken( 'rollback' ); - $errors = $page->doRollback( - $user1->getName(), - "testing revert same user", - $token, - false, - $resultDetails, - $admin - ); - - $this->assertEquals( [], $errors, "Rollback failed same user" ); - - # now, try the rollback - $resultDetails = []; - $token = $admin->getEditToken( 'rollback' ); - $errors = $page->doRollback( - $user1->getName(), - "testing revert", - $token, - false, - $resultDetails, - $admin - ); - - $this->assertEquals( - [ - [ - 'alreadyrolled', - __METHOD__, - $user1->getName(), - $admin->getName(), - ], - ], - $errors, - "Rollback not failed" - ); - - $page = new WikiPage( $page->getTitle() ); - $this->assertEquals( $rev1->getSha1(), $page->getRevision()->getSha1(), - "rollback did not revert to the correct revision" ); - $this->assertEquals( "one", $page->getContent()->getText() ); - } - - /** - * Tests tagging for edits that do rollback action - * @covers WikiPage::doRollback - */ - public function testDoRollbackTagging() { - if ( !in_array( 'mw-rollback', ChangeTags::getSoftwareTags() ) ) { - $this->markTestSkipped( 'Rollback tag deactivated, skipped the test.' ); - } - - $admin = new User(); - $admin->setName( 'Administrator' ); - $admin->addToDatabase(); - - $text = 'First line'; - $page = $this->newPage( 'WikiPageTest_testDoRollbackTagging' ); - $page->doUserEditContent( - ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), - $admin, - 'Added first line', - EDIT_NEW - ); - - $secondUser = new User(); - $secondUser->setName( '92.65.217.32' ); - $text .= '\n\nSecond line'; - $page = new WikiPage( $page->getTitle() ); - $page->doUserEditContent( - ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), - $secondUser, - 'Adding second line' - ); - - // Now, try the rollback - $admin->addGroup( 'sysop' ); // Make the test user a sysop - MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache(); - $token = $admin->getEditToken( 'rollback' ); - $errors = $page->doRollback( - $secondUser->getName(), - 'testing rollback', - $token, - false, - $resultDetails, - $admin - ); - - // If doRollback completed without errors - if ( $errors === [] ) { - $tags = $resultDetails[ 'tags' ]; - $this->assertContains( 'mw-rollback', $tags ); - } - } - public function provideGetAutoDeleteReason() { return [ [ diff --git a/tests/phpunit/integration/includes/page/RollbackPageTest.php b/tests/phpunit/integration/includes/page/RollbackPageTest.php new file mode 100644 index 00000000000..36d62fd1140 --- /dev/null +++ b/tests/phpunit/integration/includes/page/RollbackPageTest.php @@ -0,0 +1,380 @@ +setMwGlobals( 'wgUseRCPatrol', true ); + $this->tablesUsed = array_merge( $this->tablesUsed, [ + 'page', + 'recentchanges', + 'logging', + ] ); + } + + public function provideAuthorize() { + yield 'Allowed' => [ + 'authority' => $this->mockRegisteredUltimateAuthority(), + 'expect' => true, + ]; + yield 'No edit' => [ + 'authority' => $this->mockRegisteredAuthorityWithoutPermissions( [ 'edit' ] ), + 'expect' => true, + ]; + yield 'No rollback' => [ + 'authority' => $this->mockRegisteredAuthorityWithoutPermissions( [ 'rollback' ] ), + 'expect' => true, + ]; + } + + /** + * @covers ::authorizeRollback + * @dataProvider provideAuthorize + */ + public function testAuthorize( Authority $authority, bool $expect ) { + $this->assertSame( + $expect, + $this->getServiceContainer() + ->getRollbackPageFactory() + ->newRollbackPage( + new PageIdentityValue( 10, NS_MAIN, 'Test', PageIdentity::LOCAL ), + $authority, + new UserIdentityValue( 0, '127.0.0.1' ) + ) + ->authorizeRollback() + ->isGood() + ); + } + + public function testAuthorizeReadOnly() { + $mockReadOnly = $this->createMock( ReadOnlyMode::class ); + $mockReadOnly->method( 'isReadOnly' )->willReturn( true ); + $rollback = $this->newServiceInstance( RollbackPage::class, [ + 'readOnlyMode' => $mockReadOnly, + 'performer' => $this->mockRegisteredUltimateAuthority() + ] ); + $this->assertFalse( $rollback->authorizeRollback()->isGood() ); + } + + /** + * @covers ::authorizeRollback + */ + public function testAuthorizePingLimiter() { + $performer = $this->mockRegisteredUltimateAuthority(); + $userMock = $this->createMock( User::class ); + $userMock->method( 'pingLimiter' ) + ->withConsecutive( [ 'rollback', 1 ], [ 'edit', 1 ] ) + ->willReturnOnConsecutiveCalls( false, false ); + $userFactoryMock = $this->createMock( UserFactory::class ); + $userFactoryMock->method( 'newFromAuthority' ) + ->with( $performer ) + ->willReturn( $userMock ); + $rollbackPage = $this->newServiceInstance( RollbackPage::class, [ + 'performer' => $performer, + 'userFactory' => $userFactoryMock + ] ); + $this->assertTrue( $rollbackPage->authorizeRollback()->isGood() ); + } + + public function testRollbackNotAllowed() { + $this->assertFalse( $this->newServiceInstance( RollbackPage::class, [ + 'performer' => $this->mockRegisteredNullAuthority() + ] )->rollbackIfAllowed()->isGood() ); + } + + public function testRollback() { + $admin = $this->getTestSysop()->getUser(); + $user1 = $this->getTestUser()->getUser(); + // Use the confirmed group for user2 to make sure the user is different + $user2 = $this->getTestUser( [ 'confirmed' ] )->getUser(); + + $page = new WikiPage( Title::newFromText( __METHOD__ ) ); + // Make some edits + $text = "one"; + $status1 = $this->editPage( $page, $text, "section one", NS_MAIN, $admin ); + $this->assertTrue( $status1->isGood(), 'Sanity: edit 1 success' ); + + $text .= "\n\ntwo"; + $status2 = $this->editPage( $page, $text, "adding section two", NS_MAIN, $user1 ); + $this->assertTrue( $status2->isGood(), 'Sanity: edit 2 success' ); + + $text .= "\n\nthree"; + $status3 = $this->editPage( $page, $text, "adding section three", NS_MAIN, $user2 ); + $this->assertTrue( $status3->isGood(), 'Sanity: edit 3 success' ); + + /** @var RevisionRecord $rev1 */ + /** @var RevisionRecord $rev2 */ + /** @var RevisionRecord $rev3 */ + $rev1 = $status1->getValue()['revision-record']; + $rev2 = $status2->getValue()['revision-record']; + $rev3 = $status3->getValue()['revision-record']; + + /** + * We are having issues with doRollback spuriously failing. Apparently + * the last revision somehow goes missing or not committed under some + * circumstances. So, make sure the revisions have the correct usernames. + */ + $this->assertEquals( + 3, + $this->getServiceContainer() + ->getRevisionStore() + ->countRevisionsByPageId( $this->db, $page->getId() ) + ); + $this->assertEquals( $admin->getName(), $rev1->getUser()->getName() ); + $this->assertEquals( $user1->getName(), $rev2->getUser()->getName() ); + $this->assertEquals( $user2->getName(), $rev3->getUser()->getName() ); + + // Now, try the actual rollback + $rollbackStatus = $this->getServiceContainer() + ->getRollbackPageFactory() + ->newRollbackPage( $page, $admin, $user2 ) + ->rollbackIfAllowed(); + $this->assertTrue( $rollbackStatus->isGood() ); + + $this->assertEquals( + $rev2->getSha1(), + $page->getRevisionRecord()->getSha1(), + "rollback did not revert to the correct revision" ); + $this->assertEquals( "one\n\ntwo", $page->getContent()->getText() ); + + $rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange( + $page->getRevisionRecord() + ); + + $this->assertNotNull( $rc, 'RecentChanges entry' ); + $this->assertEquals( + RecentChange::PRC_AUTOPATROLLED, + $rc->getAttribute( 'rc_patrolled' ), + 'rc_patrolled' + ); + + $mainSlot = $page->getRevisionRecord()->getSlot( SlotRecord::MAIN ); + $this->assertTrue( $mainSlot->isInherited(), 'isInherited' ); + $this->assertSame( $rev2->getId(), $mainSlot->getOrigin(), 'getOrigin' ); + } + + public function testRollbackFailSameContent() { + $admin = $this->getTestSysop()->getUser(); + $page = new WikiPage( Title::newFromText( __METHOD__ ) ); + + $text = "one"; + $status1 = $this->editPage( $page, $text, "section one", NS_MAIN, $admin ); + $this->assertTrue( $status1->isGood(), 'Sanity: edit 1 success' ); + $rev1 = $page->getRevisionRecord(); + + $user1 = $this->getTestUser( [ 'sysop' ] )->getUser(); + $text .= "\n\ntwo"; + $status1 = $this->editPage( $page, $text, "adding section two", NS_MAIN, $user1 ); + $this->assertTrue( $status1->isGood(), 'Sanity: edit 2 success' ); + + $rollbackResult = $this->getServiceContainer() + ->getRollbackPageFactory() + ->newRollbackPage( $page, $admin, $user1 ) + ->rollbackIfAllowed(); + $this->assertTrue( $rollbackResult->isGood() ); + + # now, try the rollback again + $rollbackResult = $this->getServiceContainer() + ->getRollbackPageFactory() + ->newRollbackPage( $page, $admin, $user1 ) + ->rollback(); + $this->assertFalse( $rollbackResult->isGood() ); + $this->assertTrue( $rollbackResult->hasMessage( 'alreadyrolled' ) ); + + $this->assertEquals( $rev1->getSha1(), $page->getRevisionRecord()->getSha1(), + "rollback did not revert to the correct revision" ); + $this->assertEquals( "one", $page->getContent()->getText() ); + } + + public function testRollbackFailNotExisting() { + $rollbackStatus = $this->getServiceContainer() + ->getRollbackPageFactory() + ->newRollbackPage( + new PageIdentityValue( 0, NS_MAIN, __METHOD__, PageIdentityValue::LOCAL ), + $this->mockRegisteredUltimateAuthority(), + new UserIdentityValue( 0, '127.0.0.1' ) + ) + ->rollback(); + $this->assertFalse( $rollbackStatus->isGood() ); + $this->assertTrue( $rollbackStatus->hasMessage( 'notanarticle' ) ); + } + + /** + * @param Authority $user1 + * @param Authority $user2 + * @param WikiPage $page + * @return array with info about created page: + * 'revision-one' => RevisionRecord + * 'revision-two' => RevisionRecord + */ + private function prepareForRollback( Authority $user1, Authority $user2, WikiPage $page ): array { + $result = []; + $text = "one"; + $status = $this->editPage( $page, $text, "section one", NS_MAIN, $user1 ); + $this->assertTrue( $status->isGood(), 'Sanity: edit 1 success' ); + $result['revision-one'] = $status->getValue()['revision-record']; + + $text .= "\n\ntwo"; + $status = $this->editPage( $page, $text, "adding section two", NS_MAIN, $user2 ); + $this->assertTrue( $status->isGood(), 'Sanity: edit 2 success' ); + $result['revision-two'] = $status->getValue()['revision-record']; + return $result; + } + + public function testRollbackTagging() { + if ( !in_array( 'mw-rollback', ChangeTags::getSoftwareTags() ) ) { + $this->markTestSkipped( 'Rollback tag deactivated, skipped the test.' ); + } + + $page = new WikiPage( Title::newFromText( __METHOD__ ) ); + $admin = $this->getTestSysop()->getUser(); + $user1 = $this->getTestUser()->getUser(); + + $this->prepareForRollback( $admin, $user1, $page ); + + $rollbackResult = $this->getServiceContainer() + ->getRollbackPageFactory() + ->newRollbackPage( $page, $admin, $user1 ) + ->setChangeTags( [ 'tag' ] ) + ->rollbackIfAllowed(); + $this->assertTrue( $rollbackResult->isGood() ); + $this->assertContains( 'mw-rollback', $rollbackResult->getValue()['tags'] ); + $this->assertContains( 'tag', $rollbackResult->getValue()['tags'] ); + } + + public function testRollbackBot() { + $page = new WikiPage( Title::newFromText( __METHOD__ ) ); + $admin = $this->getTestSysop()->getUser(); + $user1 = $this->getTestUser()->getUser(); + + $this->prepareForRollback( $admin, $user1, $page ); + + $rollbackResult = $this->getServiceContainer() + ->getRollbackPageFactory() + ->newRollbackPage( $page, $admin, $user1 ) + ->markAsBot( true ) + ->rollbackIfAllowed(); + $this->assertTrue( $rollbackResult->isGood() ); + $rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange( $page->getRevisionRecord() ); + $this->assertNotNull( $rc ); + $this->assertSame( '1', $rc->getAttribute( 'rc_bot' ) ); + } + + public function testRollbackBotNotAllowed() { + $page = new WikiPage( Title::newFromText( __METHOD__ ) ); + $admin = $this->mockUserAuthorityWithoutPermissions( + $this->getTestSysop()->getUser(), [ 'markbotedits', 'bot' ] ); + $user1 = $this->getTestUser()->getUser(); + + $this->prepareForRollback( $admin, $user1, $page ); + + $rollbackResult = $this->getServiceContainer() + ->getRollbackPageFactory() + ->newRollbackPage( $page, $admin, $user1 ) + ->markAsBot( true ) + ->rollbackIfAllowed(); + $this->assertTrue( $rollbackResult->isGood() ); + $rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange( $page->getRevisionRecord() ); + $this->assertNotNull( $rc ); + $this->assertSame( '0', $rc->getAttribute( 'rc_bot' ) ); + } + + public function testRollbackCustomSummary() { + $page = new WikiPage( Title::newFromText( __METHOD__ ) ); + $admin = $this->getTestSysop()->getUser(); + $user1 = $this->getTestUser()->getUser(); + + $revisions = $this->prepareForRollback( $admin, $user1, $page ); + + $rollbackResult = $this->getServiceContainer() + ->getRollbackPageFactory() + ->newRollbackPage( $page, $admin, $user1 ) + ->setSummary( 'TEST! $1 $2 $3 $4 $5 $6' ) + ->rollbackIfAllowed(); + $this->assertTrue( $rollbackResult->isGood() ); + $targetTimestamp = $this->getServiceContainer() + ->getContentLanguage() + ->timeanddate( $revisions['revision-one']->getTimestamp() ); + $currentTimestamp = $this->getServiceContainer() + ->getContentLanguage() + ->timeanddate( $revisions['revision-two']->getTimestamp() ); + $expectedSummary = "TEST! {$admin->getName()} {$user1->getName()}" . + " {$revisions['revision-one']->getId()}" . + " {$targetTimestamp}" . + " {$revisions['revision-two']->getId()}" . + " {$currentTimestamp}"; + $this->assertSame( $expectedSummary, $page->getRevisionRecord()->getComment()->text ); + $rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange( $page->getRevisionRecord() ); + $this->assertNotNull( $rc ); + $this->assertSame( $expectedSummary, $rc->getAttribute( 'rc_comment' ) ); + } + + public function testRollbackChangesContentModel() { + $page = new WikiPage( Title::newFromText( __METHOD__ ) ); + $admin = $this->getTestSysop()->getUser(); + $user1 = $this->getTestUser()->getUser(); + + $status1 = $this->editPage( $page, new JsonContent( '{}' ), + "it's json", NS_MAIN, $admin ); + $this->assertTrue( $status1->isGood(), 'Sanity: edit 1 success' ); + + $status1 = $this->editPage( $page, new WikitextContent( 'bla' ), + "no, it's wikitext", NS_MAIN, $user1 ); + $this->assertTrue( $status1->isGood(), 'Sanity: edit 2 success' ); + + $rollbackResult = $this->getServiceContainer() + ->getRollbackPageFactory() + ->newRollbackPage( $page, $admin, $user1 ) + ->setSummary( 'TESTING' ) + ->rollbackIfAllowed(); + $this->assertTrue( $rollbackResult->isGood() ); + $logQuery = DatabaseLogEntry::getSelectQueryData(); + $logRow = $this->db->selectRow( + $logQuery['tables'], + $logQuery['fields'], + [ + 'log_namespace' => NS_MAIN, + 'log_title' => __METHOD__, + 'log_type' => 'contentmodel' + ], + __METHOD__, + [], + $logQuery['join_conds'] + ); + $this->assertNotNull( $logRow ); + $this->assertSame( $admin->getUser()->getName(), $logRow->user_name ); + $this->assertSame( 'TESTING', $logRow->log_comment_text ); + } +} diff --git a/tests/phpunit/unit/includes/FactoryArgTestTrait.php b/tests/phpunit/unit/includes/FactoryArgTestTrait.php index 8a586b4f3d7..d2dec2e0ef5 100644 --- a/tests/phpunit/unit/includes/FactoryArgTestTrait.php +++ b/tests/phpunit/unit/includes/FactoryArgTestTrait.php @@ -33,7 +33,7 @@ trait FactoryArgTestTrait { * @return string */ protected function getFactoryMethodName() { - return 'new' . $this->getInstanceClass(); + return 'new' . ( new ReflectionClass( $this->getInstanceClass() ) )->getShortName(); } /** diff --git a/tests/phpunit/unit/includes/page/MovePageFactoryTest.php b/tests/phpunit/unit/includes/page/MovePageFactoryTest.php index aab88dac7c9..83a73be995d 100644 --- a/tests/phpunit/unit/includes/page/MovePageFactoryTest.php +++ b/tests/phpunit/unit/includes/page/MovePageFactoryTest.php @@ -17,7 +17,20 @@ class MovePageFactoryTest extends MediaWikiUnitTestCase { } protected static function getExtraClassArgCount() { - // $to and $from - return 2; + // $to + $from - $readOnlyMode - $titleFormatter - $actorMigration + return -1; + } + + protected function getOverriddenMockValueForParam( ReflectionParameter $param ) { + if ( $param->getName() === 'config' ) { + return [ new HashConfig( + array_fill_keys( MovePage::CONSTRUCTOR_OPTIONS, 'test' ) + ) ]; + } + return []; + } + + protected function getIgnoredParamNames() { + return [ 'readOnlyMode', 'config', 'titleFormatter', 'actorMigration' ]; } } diff --git a/tests/phpunit/unit/includes/page/RollbackPageFactoryTest.php b/tests/phpunit/unit/includes/page/RollbackPageFactoryTest.php new file mode 100644 index 00000000000..88e6865bd1b --- /dev/null +++ b/tests/phpunit/unit/includes/page/RollbackPageFactoryTest.php @@ -0,0 +1,51 @@ +getName() === 'config' ) { + return [ new HashConfig( + array_fill_keys( RollbackPage::CONSTRUCTOR_OPTIONS, 'test' ) + ) ]; + } + return []; + } + + protected function getIgnoredParamNames() { + return [ + 'config', + 'namespaceInfo', + 'watchedItemStore', + 'repoGroup', + 'contentHandlerFactory', + 'spamChecker' + ]; + } +}