createNoOpMock( IConnectionProvider::class, [ 'getPrimaryDatabase' ] ); $mockProvider->method( 'getPrimaryDatabase' ) ->willReturn( $params['db'] ?? $this->createNoOpMock( IDatabase::class ) ); return new MovePage( $old, $new, new ServiceOptions( MovePage::CONSTRUCTOR_OPTIONS, $params['options'] ?? [], [ 'CategoryCollation' => 'uppercase', 'MaximumMovedPages' => 100, ] ), $mockProvider, $this->getDummyNamespaceInfo(), $this->createMock( WatchedItemStore::class ), $this->makeMockRepoGroup( [ 'Existent.jpg', 'Existent2.jpg', 'Existent-file-no-page.jpg' ] ), $this->getServiceContainer()->getContentHandlerFactory(), $this->getServiceContainer()->getRevisionStore(), $this->getServiceContainer()->getSpamChecker(), $this->getServiceContainer()->getHookContainer(), $this->getServiceContainer()->getWikiPageFactory(), $this->getServiceContainer()->getUserFactory(), $this->getServiceContainer()->getUserEditTracker(), $this->getServiceContainer()->getMovePageFactory(), $this->getServiceContainer()->getCollationFactory(), $this->getServiceContainer()->getPageUpdaterFactory(), $this->getServiceContainer()->getRestrictionStore() ); } protected function setUp(): void { parent::setUp(); // To avoid problems with namespace localization $this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' ); // Ensure we have some pages that are guaranteed to exist or not $this->getExistingTestPage( 'Existent' ); $this->getExistingTestPage( 'Existent2' ); $this->getExistingTestPage( 'File:Existent.jpg' ); $this->getExistingTestPage( 'File:Existent2.jpg' ); $this->getExistingTestPage( 'File:Non-file.jpg' ); // Special treatment as we can't just add wikitext to a JS page $this->insertPage( 'MediaWiki:Existent.js', '// Hello this is JavaScript!' ); $this->getExistingTestPage( 'Hooked in place' ); $this->getNonexistingTestPage( 'Nonexistent' ); $this->getNonexistingTestPage( 'Nonexistent2' ); $this->getNonexistingTestPage( 'File:Nonexistent.jpg' ); $this->getNonexistingTestPage( 'File:Nonexistent.png' ); $this->getNonexistingTestPage( 'File:Existent-file-no-page.jpg' ); $this->getNonexistingTestPage( 'MediaWiki:Nonexistent' ); $this->getNonexistingTestPage( 'No content allowed' ); // Set a couple of hooks for specific pages $this->setTemporaryHook( 'ContentModelCanBeUsedOn', static function ( $modelId, Title $title, &$ok ) { if ( $title->getPrefixedText() === 'No content allowed' ) { $ok = false; } } ); $this->setTemporaryHook( 'TitleIsMovable', static function ( Title $title, &$result ) { if ( strtolower( $title->getPrefixedText() ) === 'hooked in place' ) { $result = false; } } ); } /** * @dataProvider provideIsValidMove * @covers \MediaWiki\Page\MovePage::isValidMove * @covers \MediaWiki\Page\MovePage::isValidMoveTarget * @covers \MediaWiki\Page\MovePage::isValidFileMove * @covers \MediaWiki\Page\MovePage::__construct * * @param string|Title $old * @param string|Title $new * @param StatusValue $expectedStatus * @param array $extraOptions */ public function testIsValidMove( $old, $new, StatusValue $expectedStatus, array $extraOptions = [] ) { $iwLookup = $this->createMock( InterwikiLookup::class ); $iwLookup->method( 'isValidInterwiki' ) ->willReturn( true ); $this->setService( 'InterwikiLookup', $iwLookup ); $old = $old instanceof Title ? $old : Title::newFromText( $old ); $new = $new instanceof Title ? $new : Title::newFromText( $new ); $mp = $this->newMovePageWithMocks( $old, $new, [ 'options' => $extraOptions ] ); $this->assertStatusMessagesExactly( $expectedStatus, $mp->isValidMove() ); } public static function provideIsValidMove() { $ret = [ 'Valid move with redirect' => [ 'Existent', 'Nonexistent', StatusValue::newGood(), [ 'createRedirect' => true ] ], 'Valid move without redirect' => [ 'Existent', 'Nonexistent', StatusValue::newGood(), [ 'createRedirect' => false ] ], 'Self move' => [ 'Existent', 'Existent', StatusValue::newFatal( 'selfmove' ), ], 'Move from empty name' => [ Title::makeTitle( NS_MAIN, '' ), 'Nonexistent', // @todo More specific error message, or make the move valid if the page actually // exists somehow in the database StatusValue::newFatal( 'badarticleerror' ), ], 'Move to empty name' => [ 'Existent', Title::makeTitle( NS_MAIN, '' ), StatusValue::newFatal( 'movepage-invalid-target-title' ), ], 'Move to invalid name' => [ 'Existent', Title::makeTitle( NS_MAIN, '<' ), StatusValue::newFatal( 'movepage-invalid-target-title' ), ], 'Move between invalid names' => [ Title::makeTitle( NS_MAIN, '<' ), Title::makeTitle( NS_MAIN, '>' ), // @todo First error message should be more specific, or maybe we should make moving // such pages valid if they actually exist somehow in the database StatusValue::newFatal( 'movepage-source-doesnt-exist', '<' ) ->fatal( 'movepage-invalid-target-title' ), ], 'Move nonexistent' => [ 'Nonexistent', 'Nonexistent2', StatusValue::newFatal( 'movepage-source-doesnt-exist', 'Nonexistent' ), ], 'Move over existing' => [ 'Existent', 'Existent2', StatusValue::newFatal( 'articleexists', 'Existent2' ), ], 'Move from another wiki' => [ Title::makeTitle( NS_MAIN, 'Test', '', 'otherwiki' ), 'Nonexistent', StatusValue::newFatal( 'immobile-source-namespace-iw' ), ], 'Move special page' => [ 'Special:FooBar', 'Nonexistent', StatusValue::newFatal( 'immobile-source-namespace', 'Special' ), ], 'Move to another wiki' => [ 'Existent', Title::makeTitle( NS_MAIN, 'Test', '', 'otherwiki' ), StatusValue::newFatal( 'immobile-target-namespace-iw' ), ], 'Move to special page' => [ 'Existent', 'Special:FooBar', StatusValue::newFatal( 'immobile-target-namespace', 'Special' ), ], 'Move to allowed content model' => [ 'MediaWiki:Existent.js', 'MediaWiki:Nonexistent', StatusValue::newGood(), ], 'Move to prohibited content model' => [ 'Existent', 'No content allowed', StatusValue::newFatal( 'content-not-allowed-here', 'wikitext', 'No content allowed', 'main' ), ], 'Aborted by hook' => [ 'Hooked in place', 'Nonexistent', StatusValue::newFatal( 'immobile-source-namespace', '(Main)' ), ], 'Doubly aborted by hook' => [ 'Hooked in place', 'Hooked In Place', StatusValue::newFatal( 'immobile-source-namespace', '(Main)' ) ->fatal( 'immobile-target-namespace', '(Main)' ), ], 'Non-file to file' => [ 'Existent', 'File:Nonexistent.jpg', StatusValue::newFatal( 'nonfile-cannot-move-to-file' ), ], 'File to non-file' => [ 'File:Existent.jpg', 'Nonexistent', StatusValue::newFatal( 'imagenocrossnamespace' ), ], 'Existing file to non-existing file' => [ 'File:Existent.jpg', 'File:Nonexistent.jpg', StatusValue::newGood(), ], 'Existing file to existing file' => [ 'File:Existent.jpg', 'File:Existent2.jpg', StatusValue::newFatal( 'articleexists', 'File:Existent2.jpg' ), ], 'Existing file to existing file with no page' => [ 'File:Existent.jpg', 'File:Existent-file-no-page.jpg', // @todo Is this correct? Moving over an existing file with no page should succeed? StatusValue::newGood(), ], 'Existing file to name with slash' => [ 'File:Existent.jpg', 'File:Existent/slashed.jpg', StatusValue::newFatal( 'imageinvalidfilename' ), ], 'Mismatched file extension' => [ 'File:Existent.jpg', 'File:Nonexistent.png', StatusValue::newFatal( 'imagetypemismatch' ), ], 'Non-file page in the File namespace' => [ 'File:Non-file.jpg', 'File:Non-file-new.png', StatusValue::newGood(), ], 'File too long' => [ 'File:Existent.jpg', 'File:0123456789012345678901234567890123456789012345678901234567890123456789' . '0123456789012345678901234567890123456789012345678901234567890123456789' . '0123456789012345678901234567890123456789012345678901234567890123456789' . '012345678901234567890123456789-long.jpg', StatusValue::newFatal( 'filename-toolong' ), ], // The FileRepo mock does not return true for ->backendSupportsUnicodePaths() 'Non-ascii' => [ 'File:Existent.jpg', 'File:🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈.jpg', StatusValue::newFatal( 'filename-toolong' ) ->fatal( 'windows-nonascii-filename' ), ], 'Non-file move long with unicode' => [ 'File:Non-file.jpg', 'File:🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈.jpg', StatusValue::newGood() ], 'File just extension' => [ 'File:Existent.jpg', 'File:.jpg', StatusValue::newFatal( 'filename-tooshort' ) ->fatal( 'imagetypemismatch' ), ], ]; return $ret; } /** * @dataProvider provideIsValidMove * * @param string|Title $old Old name * @param string|Title $new New name * @param StatusValue $expectedStatus * @param array $extraOptions */ public function testMove( $old, $new, StatusValue $expectedStatus, array $extraOptions = [] ) { $iwLookup = $this->createMock( InterwikiLookup::class ); $iwLookup->method( 'isValidInterwiki' ) ->willReturn( true ); $this->setService( 'InterwikiLookup', $iwLookup ); $old = $old instanceof Title ? $old : Title::newFromText( $old ); $new = $new instanceof Title ? $new : Title::newFromText( $new ); $createRedirect = $extraOptions['createRedirect'] ?? true; unset( $extraOptions['createRedirect'] ); $params = [ 'options' => $extraOptions ]; if ( !$expectedStatus->isGood() ) { $obj = $this->newMovePageWithMocks( $old, $new, $params ); $status = $obj->move( $this->getTestUser()->getUser() ); $this->assertStatusMessagesExactly( $expectedStatus, $status ); } else { $oldPageId = $old->getArticleID(); $status = $this->getServiceContainer() ->getMovePageFactory() ->newMovePage( $old, $new ) ->move( $this->getTestUser()->getUser(), 'move reason', $createRedirect ); $this->assertStatusOK( $status ); $this->assertMoved( $old, $new, $oldPageId, $createRedirect ); [ 'nullRevision' => $nullRevision, 'redirectRevision' => $redirectRevision ] = $status->getValue(); $this->assertInstanceOf( RevisionRecord::class, $nullRevision ); $this->assertSame( $oldPageId, $nullRevision->getPageId() ); if ( $createRedirect ) { $this->assertInstanceOf( RevisionRecord::class, $redirectRevision ); $this->assertSame( $old->getArticleID( IDBAccessObject::READ_LATEST ), $redirectRevision->getPageId() ); } else { $this->assertNull( $redirectRevision ); } } } /** * Test for the move operation being aborted via the TitleMove hook * @covers \MediaWiki\Page\MovePage::move */ public function testMoveAbortedByTitleMoveHook() { $error = 'Preventing move operation with TitleMove hook.'; $this->setTemporaryHook( 'TitleMove', static function ( $old, $new, $user, $reason, $status ) use ( $error ) { $status->fatal( $error ); } ); $oldTitle = Title::makeTitle( NS_MAIN, 'Some old title' ); $this->editPage( $oldTitle, new WikitextContent( 'foo' ), 'bar', NS_MAIN, $this->getTestSysop()->getAuthority() ); $newTitle = Title::makeTitle( NS_MAIN, 'A brand new title' ); $mp = $this->newMovePageWithMocks( $oldTitle, $newTitle ); $user = User::newFromName( 'TitleMove tester' ); $status = $mp->move( $user, 'Reason', true ); $this->assertStatusError( $error, $status ); } /** * Test moving subpages from one page to another * @covers \MediaWiki\Page\MovePage::moveSubpages */ public function testMoveSubpages() { $name = ucfirst( __FUNCTION__ ); $subPages = [ "Talk:$name/1", "Talk:$name/2" ]; $ids = []; $pages = [ $name, "Talk:$name", "$name 2", "Talk:$name 2", ]; foreach ( array_merge( $pages, $subPages ) as $page ) { $ids[$page] = $this->createPage( $page ); } $oldTitle = Title::newFromText( "Talk:$name" ); $newTitle = Title::newFromText( "Talk:$name 2" ); $status = $this->getServiceContainer() ->getMovePageFactory() ->newMovePage( $oldTitle, $newTitle ) ->moveSubpages( $this->getTestUser()->getUser(), 'Reason', true ); $this->assertStatusGood( $status, "Moving subpages from Talk:{$name} to Talk:{$name} 2 was not completely successful." ); foreach ( $subPages as $page ) { $this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] ); } } /** * Test moving subpages from one page to another * @covers \MediaWiki\Page\MovePage::moveSubpagesIfAllowed */ public function testMoveSubpagesIfAllowed() { $name = ucfirst( __FUNCTION__ ); $subPages = [ "Talk:$name/1", "Talk:$name/2" ]; $ids = []; $pages = [ $name, "Talk:$name", "$name 2", "Talk:$name 2", ]; foreach ( array_merge( $pages, $subPages ) as $page ) { $ids[$page] = $this->createPage( $page ); } $oldTitle = Title::newFromText( "Talk:$name" ); $newTitle = Title::newFromText( "Talk:$name 2" ); $status = $this->getServiceContainer() ->getMovePageFactory() ->newMovePage( $oldTitle, $newTitle ) ->moveSubpagesIfAllowed( $this->getTestUser()->getUser(), 'Reason', true ); $this->assertStatusGood( $status, "Moving subpages from Talk:{$name} to Talk:{$name} 2 was not completely successful." ); foreach ( $subPages as $page ) { $this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] ); } } /** * Shortcut function to create a page and return its id. * * @param string $name Page to create * @return int ID of created page */ protected function createPage( $name ) { return $this->editPage( $name, 'Content' )->getNewRevision()->getPageId(); } /** * @param string $from Prefixed name of source * @param string|Title $to Prefixed name of destination * @param string|Title $id Page id of the page to move * @param bool $createRedirect */ protected function assertMoved( $from, $to, $id, bool $createRedirect = true ) { Title::clearCaches(); $fromTitle = $from instanceof Title ? $from : Title::newFromText( $from ); $toTitle = $to instanceof Title ? $to : Title::newFromText( $to ); $this->assertTrue( $toTitle->exists(), "Destination {$toTitle->getPrefixedText()} does not exist" ); if ( !$createRedirect ) { $this->assertFalse( $fromTitle->exists(), "Source {$fromTitle->getPrefixedText()} exists" ); } else { $this->assertTrue( $fromTitle->exists(), "Source {$fromTitle->getPrefixedText()} does not exist" ); $this->assertTrue( $fromTitle->isRedirect(), "Source {$fromTitle->getPrefixedText()} is not a redirect" ); $target = $this->getServiceContainer() ->getRevisionLookup() ->getRevisionByTitle( $fromTitle ) ->getContent( SlotRecord::MAIN ) ->getRedirectTarget(); $this->assertSame( $toTitle->getPrefixedText(), $target->getPrefixedText() ); } $this->assertSame( $id, $toTitle->getArticleID() ); } /** * Test redirect handling * * @covers \MediaWiki\Page\MovePage::isValidMove */ public function testRedirects() { $this->editPage( 'ExistentRedirect', '#REDIRECT [[Existent]]' ); $mp = $this->newMovePageWithMocks( Title::makeTitle( NS_MAIN, 'Existent' ), Title::makeTitle( NS_MAIN, 'ExistentRedirect' ) ); $this->assertStatusGood( $mp->isValidMove(), 'Can move over normal redirect' ); $this->editPage( 'ExistentRedirect3', '#REDIRECT [[Existent]]' ); $mp = $this->newMovePageWithMocks( Title::makeTitle( NS_MAIN, 'Existent2' ), Title::makeTitle( NS_MAIN, 'ExistentRedirect3' ) ); $this->assertStatusError( 'redirectexists', $mp->isValidMove(), 'Cannot move over redirect with a different target' ); $this->editPage( 'ExistentRedirect3', '#REDIRECT [[Existent2]]' ); $mp = $this->newMovePageWithMocks( Title::makeTitle( NS_MAIN, 'Existent' ), Title::makeTitle( NS_MAIN, 'ExistentRedirect3' ) ); $this->assertStatusError( 'articleexists', $mp->isValidMove(), 'Multi-revision redirects count as articles' ); } /** * Assert that links tables are updated after cross namespace page move (T299275). */ public function testCrossNamespaceLinksUpdate() { $title = Title::makeTitle( NS_TEMPLATE, 'Test' ); $this->getExistingTestPage( $title ); $wikitext = "[[Test]], [[Image:Existent.jpg]], {{Test}}"; $old = Title::makeTitle( NS_USER, __METHOD__ ); $this->editPage( $old, $wikitext ); $pageId = $old->getId(); // do a cross-namespace move $new = Title::makeTitle( NS_PROJECT, __METHOD__ ); $obj = $this->newMovePageWithMocks( $old, $new, [ 'db' => $this->getDb() ] ); $status = $obj->move( $this->getTestUser()->getUser() ); // sanity checks $this->assertStatusOK( $status ); $this->assertSame( $pageId, $new->getId() ); $this->assertNotSame( $pageId, $old->getId() ); // ensure links tables where updated $this->newSelectQueryBuilder() ->select( [ 'lt_namespace', 'lt_title', 'pl_from_namespace' ] ) ->from( 'pagelinks' ) ->join( 'linktarget', null, 'pl_target_id=lt_id' ) ->where( [ 'pl_from' => $pageId ] ) ->assertResultSet( [ [ NS_MAIN, 'Test', NS_PROJECT ] ] ); $targetId = MediaWikiServices::getInstance()->getLinkTargetLookup()->getLinkTargetId( $title ); $this->newSelectQueryBuilder() ->select( [ 'tl_target_id', 'tl_from_namespace' ] ) ->from( 'templatelinks' ) ->where( [ 'tl_from' => $pageId ] ) ->assertResultSet( [ [ $targetId, NS_PROJECT ] ] ); $this->newSelectQueryBuilder() ->select( [ 'il_to', 'il_from_namespace' ] ) ->from( 'imagelinks' ) ->where( [ 'il_from' => $pageId ] ) ->assertResultSet( [ [ 'Existent.jpg', NS_PROJECT ] ] ); } }