diff --git a/RELEASE-NOTES-1.37 b/RELEASE-NOTES-1.37 index 458901cdeea..62114d1fc95 100644 --- a/RELEASE-NOTES-1.37 +++ b/RELEASE-NOTES-1.37 @@ -640,6 +640,30 @@ because of Phabricator reports. getUserPermissionsErrorsExpensive instead. * Parser::mUser public access, Parser::getUser and ParserOptions::getUser were hard deprecated. +* The following methods in the Title class have been deprecated in favor of the + corresponding methods in the new RestrictionStore service (with different + names where indicated): + - areCascadeProtectionSourcesLoaded() + - areRestrictionsCascading() + - areRestrictionsLoaded() + - getAllRestrictions() + - getCascadeProtectionSources() + - getFilteredRestrictionTypes() + -> listAllRestrictionTypes() + - getRestrictionExpiry() + - getRestrictionTypes() + -> listApplicableRestrictionTypes() + - getRestrictions() + - isCascadeProtected() + - isProtected() + - isSemiProtected() + - loadRestrictionsFromRows() +* The following Title methods have been deprecated with no direct public + replacement: + - deleteTitleProtection() + - getTitleProtection() + - flushRestrictions() + - loadRestrictions() * … === Other changes in 1.37 === diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index 480b8a2fd99..f0a0c46dee9 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -69,6 +69,7 @@ use MediaWiki\Page\WikiPageFactory; use MediaWiki\Parser\ParserCacheFactory; use MediaWiki\Permissions\GroupPermissionsLookup; use MediaWiki\Permissions\PermissionManager; +use MediaWiki\Permissions\RestrictionStore; use MediaWiki\Preferences\PreferencesFactory; use MediaWiki\Revision\ContributionsLookup; use MediaWiki\Revision\RevisionFactory; @@ -1363,6 +1364,14 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'ResourceLoader' ); } + /** + * @since 1.37 + * @return RestrictionStore + */ + public function getRestrictionStore(): RestrictionStore { + return $this->getService( 'RestrictionStore' ); + } + /** * @since 1.36 * @return RevertedTagUpdateManager diff --git a/includes/Permissions/RestrictionStore.php b/includes/Permissions/RestrictionStore.php new file mode 100644 index 00000000000..755dc59a2c9 --- /dev/null +++ b/includes/Permissions/RestrictionStore.php @@ -0,0 +1,751 @@ + [ + * string[] `restrictions` => restrictions loaded for pages + * string `oldRestrictions` => legacy-formatted restrictions from page.page_restrictions + * ?string `expiry` => restrictions expiry data for pages + * ?array `create_protection` => value for getCreateProtection + * bool `cascade` => cascade restrictions on this page to included templates and images? + * array[] `cascade_sources` => the results of getCascadeProtectionSources + * bool `has_cascading` => Are cascading restrictions in effect on this page? + * ] + */ + private $cache = []; + + /** + * @param ServiceOptions $options + * @param WANObjectCache $wanCache + * @param ILoadBalancer $loadBalancer + * @param LinkCache $linkCache + * @param CommentStore $commentStore + * @param HookContainer $hookContainer + * @param PageStore $pageStore + */ + public function __construct( + ServiceOptions $options, + WANObjectCache $wanCache, + ILoadBalancer $loadBalancer, + LinkCache $linkCache, + CommentStore $commentStore, + HookContainer $hookContainer, + PageStore $pageStore + ) { + $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); + $this->options = $options; + $this->wanCache = $wanCache; + $this->loadBalancer = $loadBalancer; + $this->linkCache = $linkCache; + $this->commentStore = $commentStore; + $this->hookContainer = $hookContainer; + $this->hookRunner = new HookRunner( $hookContainer ); + $this->pageStore = $pageStore; + } + + /** + * Returns list of restrictions for specified page + * + * @param PageIdentity $page Must be local + * @param string $action Action that restrictions need to be checked for + * @return string[] Restriction levels needed to take the action. All levels are required. Note + * that restriction levels are normally user rights, but 'sysop' and 'autoconfirmed' are also + * allowed for backwards compatibility. These should be mapped to 'editprotected' and + * 'editsemiprotected' respectively. Returns an empty array if there are no restrictions set + * for this action (including for unrecognized actions). + */ + public function getRestrictions( PageIdentity $page, string $action ): array { + $page->assertWiki( PageIdentity::LOCAL ); + + $restrictions = $this->getAllRestrictions( $page ); + return $restrictions[$action] ?? []; + } + + /** + * Returns the restricted actions and their restrictions for the specified page + * + * @param PageIdentity $page Must be local + * @return string[][] Keys are actions, values are arrays as returned by + * RestrictionStore::getRestrictions(). Empty if no restrictions are in place. + */ + public function getAllRestrictions( PageIdentity $page ): array { + $page->assertWiki( PageIdentity::LOCAL ); + + if ( !$this->areRestrictionsLoaded( $page ) ) { + $this->loadRestrictions( $page ); + } + return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] ?? []; + } + + /** + * Get the expiry time for the restriction against a given action + * + * @param PageIdentity $page Must be local + * @param string $action + * @return ?string 14-char timestamp, or 'infinity' if the page is protected forever or not + * protected at all, or null if the action is not recognized. NOTE: This returns null for + * unrecognized actions, unlike Title::getRestrictionExpiry which returns false. + */ + public function getRestrictionExpiry( PageIdentity $page, string $action ): ?string { + $page->assertWiki( PageIdentity::LOCAL ); + + if ( !$this->areRestrictionsLoaded( $page ) ) { + $this->loadRestrictions( $page ); + } + return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['expiry'][$action] ?? null; + } + + /** + * Is this title subject to protection against creation? + * + * @param PageIdentity $page Must be local + * @return ?array Null if no restrictions. Otherwise an array with the following keys: + * - user: user id + * - expiry: 14-digit timestamp or 'infinity' + * - permission: string (pt_create_perm) + * - reason: string + * @internal Only to be called by Title::getTitleProtection. When that is discontinued, this + * will be too, in favor of getRestrictions( $page, 'create' ). If someone wants to know who + * protected it or the reason, there should be a method that exposes that for all restriction + * types. + */ + public function getCreateProtection( PageIdentity $page ): ?array { + $page->assertWiki( PageIdentity::LOCAL ); + + $protection = $this->getCreateProtectionInternal( $page ); + // TODO: the remapping below probably need to be migrated into other method one day + if ( $protection ) { + if ( $protection['permission'] == 'sysop' ) { + $protection['permission'] = 'editprotected'; // B/C + } + if ( $protection['permission'] == 'autoconfirmed' ) { + $protection['permission'] = 'editsemiprotected'; // B/C + } + } + return $protection; + } + + /** + * Remove any title creation protection due to page existing + * + * @param PageIdentity $page Must be local + * @internal Only to be called by WikiPage::onArticleCreate. + */ + public function deleteCreateProtection( PageIdentity $page ): void { + $page->assertWiki( PageIdentity::LOCAL ); + + $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY ); + $dbw->delete( + 'protected_titles', + [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ], + __METHOD__ + ); + $this->cache[CacheKeyHelper::getKeyForPage( $page )]['create_protection'] = null; + } + + /** + * Is this page "semi-protected" - the *only* protection levels are listed in + * $wgSemiprotectedRestrictionLevels? + * + * @param PageIdentity $page Must be local + * @param string $action Action to check (default: edit) + * @return bool + */ + public function isSemiProtected( PageIdentity $page, string $action = 'edit' ): bool { + $page->assertWiki( PageIdentity::LOCAL ); + + $restrictions = $this->getRestrictions( $page, $action ); + $semi = $this->options->get( 'SemiprotectedRestrictionLevels' ); + if ( !$restrictions || !$semi ) { + // Not protected, or all protection is full protection + return false; + } + + // Remap autoconfirmed to editsemiprotected for BC + foreach ( array_keys( $semi, 'editsemiprotected' ) as $key ) { + $semi[$key] = 'autoconfirmed'; + } + foreach ( array_keys( $restrictions, 'editsemiprotected' ) as $key ) { + $restrictions[$key] = 'autoconfirmed'; + } + + return !array_diff( $restrictions, $semi ); + } + + /** + * Does the title correspond to a protected article? + * + * @param PageIdentity $page Must be local + * @param string $action The action the page is protected from, by default checks all actions. + * @return bool + */ + public function isProtected( PageIdentity $page, string $action = '' ): bool { + $page->assertWiki( PageIdentity::LOCAL ); + + // Special pages have inherent protection (TODO: remove after switch to ProperPageIdentity) + if ( $page->getNamespace() === NS_SPECIAL ) { + return true; + } + + // Check regular protection levels + $applicableTypes = $this->listApplicableRestrictionTypes( $page ); + + if ( $action === '' ) { + foreach ( $applicableTypes as $type ) { + if ( $this->isProtected( $page, $type ) ) { + return true; + } + } + return false; + } + + if ( !in_array( $action, $applicableTypes ) ) { + return false; + } + + return (bool)array_diff( + array_intersect( + $this->getRestrictions( $page, $action ), + $this->options->get( 'RestrictionLevels' ) + ), + [ '' ] + ); + } + + /** + * Cascading protection: Return true if cascading restrictions apply to this page, false if not. + * + * @param PageIdentity $page Must be local + * @return bool If the page is subject to cascading restrictions. + */ + public function isCascadeProtected( PageIdentity $page ): bool { + $page->assertWiki( PageIdentity::LOCAL ); + + return $this->getCascadeProtectionSourcesInternal( $page, true ); + } + + /** + * Returns restriction types for the current page + * + * @param PageIdentity $page Must be local + * @return string[] Applicable restriction types + */ + public function listApplicableRestrictionTypes( PageIdentity $page ): array { + $page->assertWiki( PageIdentity::LOCAL ); + + if ( !$page->canExist() ) { + return []; + } + + $types = $this->listAllRestrictionTypes( $page->exists() ); + + if ( $page->getNamespace() !== NS_FILE ) { + // Remove the upload restriction for non-file titles + $types = array_values( array_diff( $types, [ 'upload' ] ) ); + } + + if ( $this->hookContainer->isRegistered( 'TitleGetRestrictionTypes' ) ) { + $this->hookRunner->onTitleGetRestrictionTypes( + Title::castFromPageIdentity( $page ), $types ); + } + + return $types; + } + + /** + * Get a filtered list of all restriction types supported by this wiki. + * + * @param bool $exists True to get all restriction types that apply to titles that do exist, + * false for all restriction types that apply to titles that do not exist + * @return string[] + */ + public function listAllRestrictionTypes( bool $exists = true ): array { + $types = $this->options->get( 'RestrictionTypes' ); + if ( $exists ) { + // Remove the create restriction for existing titles + return array_values( array_diff( $types, [ 'create' ] ) ); + } + + // Only the create and upload restrictions apply to non-existing titles + return array_values( array_intersect( $types, [ 'create', 'upload' ] ) ); + } + + /** + * Load restrictions from page.page_restrictions and the page_restrictions table + * + * @param PageIdentity $page Must be local + * @param int $flags IDBAccessObject::READ_XXX constants (e.g., READ_LATEST to read from + * primary DB) + * @param string|null $oldRestrictions Restrictions in legacy format (page.page_restrictions). + * null means we don't know about any legacy restrictions and they need to be looked up. + * Example: "edit=autoconfirmed,sysop:move=sysop" + * @internal + */ + public function loadRestrictions( + PageIdentity $page, int $flags = IDBAccessObject::READ_NORMAL, ?string $oldRestrictions = null + ): void { + $page->assertWiki( PageIdentity::LOCAL ); + + if ( !$page->canExist() ) { + return; + } + + $readLatest = DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST ); + + if ( $this->areRestrictionsLoaded( $page ) && !$readLatest ) { + return; + } + + $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )]; + + $cacheEntry['restrictions'] = []; + + // XXX Work around https://phabricator.wikimedia.org/T287575 + if ( $readLatest ) { + $page = $this->pageStore->getPageByReference( $page, $flags ) ?? $page; + } + $id = $page->getId(); + if ( $id ) { + $fname = __METHOD__; + $loadRestrictionsFromDb = static function ( IDatabase $dbr ) use ( $fname, $id ) { + return iterator_to_array( + $dbr->select( + 'page_restrictions', + [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ], + [ 'pr_page' => $id ], + $fname + ) + ); + }; + + if ( $readLatest ) { + $dbr = $this->loadBalancer->getConnectionRef( DB_PRIMARY ); + $rows = $loadRestrictionsFromDb( $dbr ); + } else { + $this->linkCache->addLinkObj( $page ); + $latestRev = $this->linkCache->getGoodLinkFieldObj( $page, 'revision' ); + $rows = $this->wanCache->getWithSetCallback( + // Page protections always leave a new null revision + $this->wanCache->makeKey( 'page-restrictions', 'v1', $id, $latestRev ), + $this->wanCache::TTL_DAY, + function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) { + $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA ); + $setOpts += Database::getCacheSetOptions( $dbr ); + if ( $this->loadBalancer->hasOrMadeRecentMasterChanges() ) { + // TODO: cleanup Title cache and caller assumption mess in general + $ttl = WANObjectCache::TTL_UNCACHEABLE; + } + + return $loadRestrictionsFromDb( $dbr ); + } + ); + } + + $this->loadRestrictionsFromRows( $page, $rows, $oldRestrictions ); + } else { + $titleProtection = $this->getCreateProtectionInternal( $page ); + + if ( $titleProtection ) { + $now = wfTimestampNow(); + $expiry = $titleProtection['expiry']; + + if ( !$expiry || $expiry > $now ) { + // Apply the restrictions + $cacheEntry['expiry']['create'] = $expiry ?: null; + $cacheEntry['restrictions']['create'] = + explode( ',', trim( $titleProtection['permission'] ) ); + } else { + // Get rid of the old restrictions + $cacheEntry['create_protection'] = null; + } + } else { + $cacheEntry['expiry']['create'] = 'infinity'; + } + } + } + + /** + * Compiles list of active page restrictions for this existing page. + * Public for usage by LiquidThreads. + * + * @param PageIdentity $page Must be local + * @param stdClass[] $rows Array of db result objects + * @param string|null $oldRestrictions Restrictions in legacy format (page.page_restrictions). + * null means we don't know about any legacy restrictions and they need to be looked up. + * Example: "edit=autoconfirmed,sysop:move=sysop" + */ + public function loadRestrictionsFromRows( + PageIdentity $page, array $rows, ?string $oldRestrictions = null + ): void { + $page->assertWiki( PageIdentity::LOCAL ); + + $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )]; + + $restrictionTypes = $this->listApplicableRestrictionTypes( $page ); + + foreach ( $restrictionTypes as $type ) { + $cacheEntry['restrictions'][$type] = []; + $cacheEntry['expiry'][$type] = 'infinity'; + } + + $cacheEntry['cascade'] = false; + + // Backwards-compatibility: also load the restrictions from the page record (old format). + // Don't include in test coverage, we're planning to drop support. + // @codeCoverageIgnoreStart + $cacheEntry['oldRestrictions'] = $oldRestrictions ?? $cacheEntry['oldRestrictions'] ?? null; + + if ( $cacheEntry['oldRestrictions'] === null ) { + $this->linkCache->addLinkObj( $page ); + $cachedOldRestrictions = $this->linkCache->getGoodLinkFieldObj( $page, 'restrictions' ); + if ( $cachedOldRestrictions !== null ) { + $cacheEntry['oldRestrictions'] = $cachedOldRestrictions; + } + } + + if ( $cacheEntry['oldRestrictions'] ) { + $cacheEntry['restrictions'] = + $this->convertOldRestrictions( $cacheEntry['oldRestrictions'] ); + } + // @codeCoverageIgnoreEnd + + if ( !$rows ) { + return; + } + + // New restriction format -- load second to make them override old-style restrictions. + $now = wfTimestampNow(); + + // Cycle through all the restrictions. + foreach ( $rows as $row ) { + // Don't take care of restrictions types that aren't allowed + if ( !in_array( $row->pr_type, $restrictionTypes ) ) { + continue; + } + + $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA ); + $expiry = $dbr->decodeExpiry( $row->pr_expiry ); + + // Only apply the restrictions if they haven't expired! + // XXX Why would !$expiry ever be true? It should always be either 'infinity' or a + // string consisting of 14 digits. Likewise for the ?: below. + if ( !$expiry || $expiry > $now ) { + $cacheEntry['expiry'][$row->pr_type] = $expiry ?: null; + $cacheEntry['restrictions'][$row->pr_type] + = explode( ',', trim( $row->pr_level ) ); + if ( $row->pr_cascade ) { + $cacheEntry['cascade'] = true; + } + } + } + } + + /** + * Given a string formatted like the legacy page.page_restrictions field, return an array of + * restrictions in the format returned by getAllRestrictions(). + * + * @param string $oldRestrictions Restrictions in legacy format (page.page_restrictions). + * Example: "edit=autoconfirmed,sysop:move=sysop" + * @return array As returned by getAllRestrictions() + * @codeCoverageIgnore We're planning to drop support for this + */ + private function convertOldRestrictions( string $oldRestrictions ): array { + $ret = []; + foreach ( explode( ':', trim( $oldRestrictions ) ) as $restrict ) { + $restrictionPair = explode( '=', trim( $restrict ) ); + if ( count( $restrictionPair ) == 1 ) { + // old old format should be treated as edit/move restriction + $ret['edit'] = explode( ',', trim( $restrictionPair[0] ) ); + $ret['move'] = explode( ',', trim( $restrictionPair[0] ) ); + } else { + $restriction = trim( $restrictionPair[1] ); + if ( $restriction != '' ) { // some old entries are empty + $ret[$restrictionPair[0]] = explode( ',', $restriction ); + } + } + } + return $ret; + } + + /** + * Fetch title protection settings + * + * To work correctly, $this->loadRestrictions() needs to have access to the actual protections + * in the database without munging 'sysop' => 'editprotected' and 'autoconfirmed' => + * 'editsemiprotected'. + * + * @param PageIdentity $page Must be local + * @return ?array Same format as getCreateProtection(). + */ + private function getCreateProtectionInternal( PageIdentity $page ): ?array { + // Can't protect pages in special namespaces + if ( !$page->canExist() ) { + return null; + } + + // Can't apply this type of protection to pages that exist. + if ( $page->exists() ) { + return null; + } + + $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )]; + + if ( !$cacheEntry || !array_key_exists( 'create_protection', $cacheEntry ) ) { + $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA ); + $commentQuery = $this->commentStore->getJoin( 'pt_reason' ); + $row = $dbr->selectRow( + [ 'protected_titles' ] + $commentQuery['tables'], + [ 'pt_user', 'pt_expiry', 'pt_create_perm' ] + $commentQuery['fields'], + [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ], + __METHOD__, + [], + $commentQuery['joins'] + ); + + if ( $row ) { + $cacheEntry['create_protection'] = [ + 'user' => $row->pt_user, + 'expiry' => $dbr->decodeExpiry( $row->pt_expiry ), + 'permission' => $row->pt_create_perm, + 'reason' => $this->commentStore->getComment( 'pt_reason', $row )->text, + ]; + } else { + $cacheEntry['create_protection'] = null; + } + + } + + return $cacheEntry['create_protection']; + } + + /** + * Cascading protection: Get the source of any cascading restrictions on this page. + * + * @param PageIdentity $page Must be local + * @return array[] Two elements: First is an array of PageIdentity objects of the pages from + * which cascading restrictions have come, which may be empty. Second is an array like that + * returned by getAllRestrictions(). NOTE: The first element of the return is always an + * array, unlike Title::getCascadeProtectionSources where the first element is false if there + * are no sources. + */ + public function getCascadeProtectionSources( PageIdentity $page ): array { + $page->assertWiki( PageIdentity::LOCAL ); + + return $this->getCascadeProtectionSourcesInternal( $page, false ); + } + + /** + * Cascading protection: Get the source of any cascading restrictions on this page. + * + * @param PageIdentity $page Must be local + * @param bool $shortCircuit If true, just return true or false instead of the actual lists. + * @return array|bool If $shortCircuit is true, return true if there is some cascading + * protection and false otherwise. Otherwise, same as getCascadeProtectionSources(). + */ + private function getCascadeProtectionSourcesInternal( + PageIdentity $page, bool $shortCircuit = false + ) { + $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )]; + + if ( !$shortCircuit && isset( $cacheEntry['cascade_sources'] ) ) { + return $cacheEntry['cascade_sources']; + } elseif ( $shortCircuit && isset( $cacheEntry['has_cascading'] ) ) { + return $cacheEntry['has_cascading']; + } + + if ( $page->getNamespace() === NS_FILE ) { + // Files transclusion may receive cascading protection in the future + // see https://phabricator.wikimedia.org/T241453 + $tables = [ 'imagelinks', 'page_restrictions' ]; + $where_clauses = [ + 'il_to' => $page->getDBkey(), + 'il_from=pr_page', + 'pr_cascade' => 1 + ]; + } else { + $tables = [ 'templatelinks', 'page_restrictions' ]; + $where_clauses = [ + 'tl_namespace' => $page->getNamespace(), + 'tl_title' => $page->getDBkey(), + 'tl_from=pr_page', + 'pr_cascade' => 1 + ]; + } + + if ( $shortCircuit ) { + $cols = [ 'pr_expiry' ]; + } else { + $cols = [ 'pr_page', 'page_namespace', 'page_title', + 'pr_expiry', 'pr_type', 'pr_level' ]; + $where_clauses[] = 'page_id=pr_page'; + $tables[] = 'page'; + } + + $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA ); + $res = $dbr->select( $tables, $cols, $where_clauses, __METHOD__ ); + + $sources = []; + $pageRestrictions = []; + $now = wfTimestampNow(); + + foreach ( $res as $row ) { + $expiry = $dbr->decodeExpiry( $row->pr_expiry ); + if ( $expiry > $now ) { + if ( $shortCircuit ) { + $cacheEntry['has_cascading'] = true; + return true; + } + + $sources[$row->pr_page] = new PageIdentityValue( $row->pr_page, + $row->page_namespace, $row->page_title, PageIdentity::LOCAL ); + // Add groups needed for each restriction type if its not already there + // Make sure this restriction type still exists + + if ( !isset( $pageRestrictions[$row->pr_type] ) ) { + $pageRestrictions[$row->pr_type] = []; + } + + if ( !in_array( $row->pr_level, $pageRestrictions[$row->pr_type] ) ) { + $pageRestrictions[$row->pr_type][] = $row->pr_level; + } + } + } + + $cacheEntry['has_cascading'] = (bool)$sources; + + if ( $shortCircuit ) { + return false; + } + + $cacheEntry['cascade_sources'] = [ $sources, $pageRestrictions ]; + return [ $sources, $pageRestrictions ]; + } + + /** + * @param PageIdentity $page Must be local + * @return bool Whether or not the page's restrictions have already been loaded from the + * database + */ + public function areRestrictionsLoaded( PageIdentity $page ): bool { + $page->assertWiki( PageIdentity::LOCAL ); + + return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] ); + } + + /** + * Determines whether cascading protection sources have already been loaded from the database. + * + * @param PageIdentity $page Must be local + * @return bool + */ + public function areCascadeProtectionSourcesLoaded( PageIdentity $page ): bool { + $page->assertWiki( PageIdentity::LOCAL ); + + return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade_sources'] ); + } + + /** + * Checks if restrictions are cascading for the current page + * + * @param PageIdentity $page Must be local + * @return bool + */ + public function areRestrictionsCascading( PageIdentity $page ): bool { + $page->assertWiki( PageIdentity::LOCAL ); + + if ( !$this->areRestrictionsLoaded( $page ) ) { + $this->loadRestrictions( $page ); + } + return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade'] ?? false; + } + + /** + * Flush the protection cache in this object and force reload from the database. This is used + * when updating protection from WikiPage::doUpdateRestrictions(). + * + * @param PageIdentity $page Must be local + * @internal + */ + public function flushRestrictions( PageIdentity $page ): void { + $page->assertWiki( PageIdentity::LOCAL ); + + unset( $this->cache[CacheKeyHelper::getKeyForPage( $page )] ); + } + + /** + * Register legacy restrictions from page.page_restrictions. This is nice to do if you have a + * page row handy anyway, so we don't have to look them up separately later. + * + * @param PageIdentity $page Must be local + * @param string $oldRestrictions Restrictions in legacy format (page.page_restrictions). + * Example: "edit=autoconfirmed,sysop:move=sysop" + * @internal + * @codeCoverageIgnore We're planning to drop support for this + */ + public function registerOldRestrictions( PageIdentity $page, string $oldRestrictions ): void { + $page->assertWiki( PageIdentity::LOCAL ); + + $this->cache[CacheKeyHelper::getKeyForPage( $page )]['oldRestrictions'] = + $oldRestrictions; + } + +} diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index 7ae56af79ba..5a1e1af976b 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -100,6 +100,7 @@ use MediaWiki\Page\WikiPageFactory; use MediaWiki\Parser\ParserCacheFactory; use MediaWiki\Permissions\GroupPermissionsLookup; use MediaWiki\Permissions\PermissionManager; +use MediaWiki\Permissions\RestrictionStore; use MediaWiki\Preferences\DefaultPreferencesFactory; use MediaWiki\Preferences\PreferencesFactory; use MediaWiki\Revision\ContributionsLookup; @@ -1324,6 +1325,20 @@ return [ return $rl; }, + 'RestrictionStore' => static function ( MediaWikiServices $services ): RestrictionStore { + return new RestrictionStore( + new ServiceOptions( + RestrictionStore::CONSTRUCTOR_OPTIONS, $services->getMainConfig() + ), + $services->getMainWANObjectCache(), + $services->getDBLoadBalancer(), + $services->getLinkCache(), + $services->getCommentStore(), + $services->getHookContainer(), + $services->getPageStore() + ); + }, + 'RevertedTagUpdateManager' => static function ( MediaWikiServices $services ): RevertedTagUpdateManager { $editResultCache = new EditResultCache( $services->getMainObjectStash(), diff --git a/includes/Title.php b/includes/Title.php index 2915d5625b6..65b7a46a0b1 100644 --- a/includes/Title.php +++ b/includes/Title.php @@ -35,7 +35,6 @@ use MediaWiki\Page\PageStoreRecord; use MediaWiki\Page\ProperPageIdentity; use Wikimedia\Assert\Assert; use Wikimedia\Assert\PreconditionException; -use Wikimedia\Rdbms\Database; use Wikimedia\Rdbms\IDatabase; /** @@ -47,6 +46,7 @@ use Wikimedia\Rdbms\IDatabase; * and does not rely on global state or the database. */ class Title implements LinkTarget, PageIdentity, IDBAccessObject { + use DeprecationHelper; use WikiAwareEntityTrait; /** @var MapCacheLRU|null */ @@ -149,35 +149,6 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { /** @var int Estimated number of revisions; null of not loaded */ private $mEstimateRevisions; - /** @var array Array of groups allowed to edit this article */ - public $mRestrictions = []; - - /** - * @var string|bool Comma-separated set of permission keys - * indicating who can move or edit the page from the page table, (pre 1.10) rows. - * Edit and move sections are separated by a colon - * Example: "edit=autoconfirmed,sysop:move=sysop" - */ - protected $mOldRestrictions = false; - - /** @var bool Cascade restrictions on this page to included templates and images? */ - public $mCascadeRestriction; - - /** Caching the results of getCascadeProtectionSources */ - public $mCascadingRestrictions; - - /** @var array When do the restrictions on this page expire? */ - protected $mRestrictionsExpiry = []; - - /** @var bool Are cascading restrictions in effect on this page? */ - protected $mHasCascadingRestrictions; - - /** @var array Where are the cascading restrictions coming from on this page? */ - public $mCascadeSources; - - /** @var bool Boolean for initialisation on demand */ - public $mRestrictionsLoaded = false; - /** * Text form including namespace/interwiki, initialised on demand * @@ -188,9 +159,6 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { */ public $prefixedText = null; - /** @var mixed Cached value for getTitleProtection (create protection) */ - public $mTitleProtection; - /** * @var int Namespace index when there is no namespace. Don't change the * following default, NS_MAIN is hardcoded in several places. See T2696. @@ -644,7 +612,10 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { $this->mDbPageLanguage = (string)$row->page_lang; } if ( isset( $row->page_restrictions ) ) { - $this->mOldRestrictions = $row->page_restrictions; + // If we have them handy, save them so we don't need to look them up later + MediaWikiServices::getInstance()->getRestrictionStore() + ->registerOldRestrictions( $this, $row->page_restrictions ); + } } else { // page not found $this->mArticleID = 0; @@ -2470,195 +2441,84 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { /** * Get a filtered list of all restriction types supported by this wiki. + * + * @deprecated since 1.37, use RestrictionStore::listAllRestrictionTypes instead + * * @param bool $exists True to get all restriction types that apply to * titles that do exist, False for all restriction types that apply to * titles that do not exist * @return array */ public static function getFilteredRestrictionTypes( $exists = true ) { - global $wgRestrictionTypes; - $types = $wgRestrictionTypes; - if ( $exists ) { - # Remove the create restriction for existing titles - $types = array_diff( $types, [ 'create' ] ); - } else { - # Only the create and upload restrictions apply to non-existing titles - $types = array_intersect( $types, [ 'create', 'upload' ] ); - } - return $types; + return MediaWikiServices::getInstance() + ->getRestrictionStore() + ->listAllRestrictionTypes( $exists ); } /** * Returns restriction types for the current Title * + * @deprecated since 1.37, use RestrictionStore::listApplicableRestrictionTypes instead + * * @return array Applicable restriction types */ public function getRestrictionTypes() { - if ( $this->isSpecialPage() ) { - return []; - } - - $types = self::getFilteredRestrictionTypes( $this->exists() ); - - if ( $this->mNamespace !== NS_FILE ) { - # Remove the upload restriction for non-file titles - $types = array_diff( $types, [ 'upload' ] ); - } - - Hooks::runner()->onTitleGetRestrictionTypes( $this, $types ); - - wfDebug( __METHOD__ . ': applicable restrictions to [[' . - $this->getPrefixedText() . ']] are {' . implode( ',', $types ) . "}" ); - - return $types; + return MediaWikiServices::getInstance() + ->getRestrictionStore() + ->listApplicableRestrictionTypes( $this ); } /** * Is this title subject to title protection? * Title protection is the one applied against creation of such title. * + * @deprecated since 1.37, use RestrictionStore::getRestrictions() instead + * * @return array|bool An associative array representing any existent title * protection, or false if there's none. */ public function getTitleProtection() { - $protection = $this->getTitleProtectionInternal(); - if ( $protection ) { - if ( $protection['permission'] == 'sysop' ) { - $protection['permission'] = 'editprotected'; // B/C - } - if ( $protection['permission'] == 'autoconfirmed' ) { - $protection['permission'] = 'editsemiprotected'; // B/C - } - } - return $protection; - } - - /** - * Fetch title protection settings - * - * To work correctly, $this->loadRestrictions() needs to have access to the - * actual protections in the database without munging 'sysop' => - * 'editprotected' and 'autoconfirmed' => 'editsemiprotected'. Other - * callers probably want $this->getTitleProtection() instead. - * - * @return array|bool - */ - protected function getTitleProtectionInternal() { - // Can't protect pages in special namespaces - if ( $this->mNamespace < 0 ) { - return false; - } - - // Can't protect pages that exist. - if ( $this->exists() ) { - return false; - } - - if ( $this->mTitleProtection === null ) { - $dbr = wfGetDB( DB_REPLICA ); - $commentStore = CommentStore::getStore(); - $commentQuery = $commentStore->getJoin( 'pt_reason' ); - $res = $dbr->select( - [ 'protected_titles' ] + $commentQuery['tables'], - [ - 'user' => 'pt_user', - 'expiry' => 'pt_expiry', - 'permission' => 'pt_create_perm' - ] + $commentQuery['fields'], - [ 'pt_namespace' => $this->mNamespace, 'pt_title' => $this->mDbkeyform ], - __METHOD__, - [], - $commentQuery['joins'] - ); - - // fetchRow returns false if there are no rows. - $row = $dbr->fetchRow( $res ); - if ( $row ) { - $this->mTitleProtection = [ - 'user' => $row['user'], - 'expiry' => $dbr->decodeExpiry( $row['expiry'] ), - 'permission' => $row['permission'], - 'reason' => $commentStore->getComment( 'pt_reason', $row )->text, - ]; - } else { - $this->mTitleProtection = false; - } - } - return $this->mTitleProtection; + return MediaWikiServices::getInstance()->getRestrictionStore()->getCreateProtection( $this ) + ?: false; } /** * Remove any title protection due to page existing + * + * @deprecated since 1.37, do not use (this is only for WikiPage::onArticleCreate) */ public function deleteTitleProtection() { - $dbw = wfGetDB( DB_PRIMARY ); - - $dbw->delete( - 'protected_titles', - [ 'pt_namespace' => $this->mNamespace, 'pt_title' => $this->mDbkeyform ], - __METHOD__ - ); - $this->mTitleProtection = false; + MediaWikiServices::getInstance()->getRestrictionStore()->deleteCreateProtection( $this ); } /** * Is this page "semi-protected" - the *only* protection levels are listed * in $wgSemiprotectedRestrictionLevels? * + * @deprecated since 1.37, use RestrictionStore::isSemiProtected instead + * * @param string $action Action to check (default: edit) * @return bool */ public function isSemiProtected( $action = 'edit' ) { - global $wgSemiprotectedRestrictionLevels; - - $restrictions = $this->getRestrictions( $action ); - $semi = $wgSemiprotectedRestrictionLevels; - if ( !$restrictions || !$semi ) { - // Not protected, or all protection is full protection - return false; - } - - // Remap autoconfirmed to editsemiprotected for BC - foreach ( array_keys( $semi, 'autoconfirmed' ) as $key ) { - $semi[$key] = 'editsemiprotected'; - } - foreach ( array_keys( $restrictions, 'autoconfirmed' ) as $key ) { - $restrictions[$key] = 'editsemiprotected'; - } - - return !array_diff( $restrictions, $semi ); + return MediaWikiServices::getInstance()->getRestrictionStore()->isSemiProtected( + $this, $action + ); } /** * Does the title correspond to a protected article? * + * @deprecated since 1.37, use RestrictionStore::isProtected instead + * * @param string $action The action the page is protected from, * by default checks all actions. * @return bool */ public function isProtected( $action = '' ) { - global $wgRestrictionLevels; - - $restrictionTypes = $this->getRestrictionTypes(); - - # Special pages have inherent protection - if ( $this->isSpecialPage() ) { - return true; - } - - # Check regular protection levels - foreach ( $restrictionTypes as $type ) { - if ( $action == $type || $action == '' ) { - $r = $this->getRestrictions( $type ); - foreach ( $wgRestrictionLevels as $level ) { - if ( in_array( $level, $r ) && $level != '' ) { - return true; - } - } - } - } - - return false; + return MediaWikiServices::getInstance()->getRestrictionStore()->isProtected( + $this, $action + ); } /** @@ -2688,29 +2548,33 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { /** * Cascading protection: Return true if cascading restrictions apply to this page, false if not. * + * @deprecated since 1.37, use RestrictionStore::isCascadeProtected instead + * * @return bool If the page is subject to cascading restrictions. */ public function isCascadeProtected() { - list( $isCascadeProtected, ) = $this->getCascadeProtectionSources( false ); - return $isCascadeProtected; + return MediaWikiServices::getInstance()->getRestrictionStore()->isCascadeProtected( $this ); } /** * Determines whether cascading protection sources have already been loaded from * the database. * - * @param bool $getPages True to check if the pages are loaded, or false to check - * if the status is loaded. - * @return bool Whether or not the specified information has been loaded + * @deprecated since 1.37, use RestrictionStore::areCascadeProtectionSourcesLoaded instead + * + * @return bool * @since 1.23 */ - public function areCascadeProtectionSourcesLoaded( $getPages = true ) { - return $getPages ? $this->mCascadeSources !== null : $this->mHasCascadingRestrictions !== null; + public function areCascadeProtectionSourcesLoaded() { + return MediaWikiServices::getInstance()->getRestrictionStore() + ->areCascadeProtectionSourcesLoaded( $this ); } /** * Cascading protection: Get the source of any cascading restrictions on this page. * + * @deprecated since 1.37, use RestrictionStore::getCascadeProtectionSources instead + * * @param bool $getPages Whether or not to retrieve the actual pages * that the restrictions have come from and the actual restrictions * themselves. @@ -2722,98 +2586,39 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { * false. */ public function getCascadeProtectionSources( $getPages = true ) { - $pagerestrictions = []; - - if ( $this->mCascadeSources !== null && $getPages ) { - return [ $this->mCascadeSources, $this->mCascadingRestrictions ]; - } elseif ( $this->mHasCascadingRestrictions !== null && !$getPages ) { - return [ $this->mHasCascadingRestrictions, $pagerestrictions ]; + $restrictionStore = MediaWikiServices::getInstance()->getRestrictionStore(); + if ( !$getPages ) { + return [ $restrictionStore->isCascadeProtected( $this ), [] ]; } - $dbr = wfGetDB( DB_REPLICA ); - - if ( $this->mNamespace === NS_FILE ) { - $tables = [ 'imagelinks', 'page_restrictions' ]; - $where_clauses = [ - 'il_to' => $this->mDbkeyform, - 'il_from=pr_page', - 'pr_cascade' => 1 - ]; - } else { - $tables = [ 'templatelinks', 'page_restrictions' ]; - $where_clauses = [ - 'tl_namespace' => $this->mNamespace, - 'tl_title' => $this->mDbkeyform, - 'tl_from=pr_page', - 'pr_cascade' => 1 - ]; + $ret = $restrictionStore->getCascadeProtectionSources( $this ); + $ret[0] = array_map( 'Title::castFromPageIdentity', $ret[0] ); + if ( !$ret[0] ) { + $ret[0] = false; } - - if ( $getPages ) { - $cols = [ 'pr_page', 'page_namespace', 'page_title', - 'pr_expiry', 'pr_type', 'pr_level' ]; - $where_clauses[] = 'page_id=pr_page'; - $tables[] = 'page'; - } else { - $cols = [ 'pr_expiry' ]; - } - - $res = $dbr->select( $tables, $cols, $where_clauses, __METHOD__ ); - - $sources = $getPages ? [] : false; - $now = wfTimestampNow(); - - foreach ( $res as $row ) { - $expiry = $dbr->decodeExpiry( $row->pr_expiry ); - if ( $expiry > $now ) { - if ( $getPages ) { - $page_id = $row->pr_page; - $page_ns = $row->page_namespace; - $page_title = $row->page_title; - $sources[$page_id] = self::makeTitle( $page_ns, $page_title ); - # Add groups needed for each restriction type if its not already there - # Make sure this restriction type still exists - - if ( !isset( $pagerestrictions[$row->pr_type] ) ) { - $pagerestrictions[$row->pr_type] = []; - } - - if ( - isset( $pagerestrictions[$row->pr_type] ) - && !in_array( $row->pr_level, $pagerestrictions[$row->pr_type] ) - ) { - $pagerestrictions[$row->pr_type][] = $row->pr_level; - } - } else { - $sources = true; - } - } - } - - if ( $getPages ) { - $this->mCascadeSources = $sources; - $this->mCascadingRestrictions = $pagerestrictions; - } else { - $this->mHasCascadingRestrictions = $sources; - } - - return [ $sources, $pagerestrictions ]; + return $ret; } /** * Accessor for mRestrictionsLoaded * + * @deprecated since 1.37, use RestrictionStore::areRestrictionsLoaded instead + * * @return bool Whether or not the page's restrictions have already been * loaded from the database * @since 1.23 */ public function areRestrictionsLoaded() { - return $this->mRestrictionsLoaded; + return MediaWikiServices::getInstance() + ->getRestrictionStore() + ->areRestrictionsLoaded( $this ); } /** * Accessor/initialisation for mRestrictions * + * @deprecated since 1.37, use RestrictionStore::getRestrictions instead + * * @param string $action Action that permission needs to be checked for * @return array Restriction levels needed to take the action. All levels are * required. Note that restriction levels are normally user rights, but 'sysop' @@ -2821,51 +2626,48 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { * be mapped to 'editprotected' and 'editsemiprotected' respectively. */ public function getRestrictions( $action ) { - if ( !$this->mRestrictionsLoaded ) { - $this->loadRestrictions(); - } - return $this->mRestrictions[$action] ?? []; + return MediaWikiServices::getInstance()->getRestrictionStore()->getRestrictions( $this, $action ); } /** * Accessor/initialisation for mRestrictions * + * @deprecated since 1.37, use RestrictionStore::getAllRestrictions instead + * * @return array Keys are actions, values are arrays as returned by * Title::getRestrictions() * @since 1.23 */ public function getAllRestrictions() { - if ( !$this->mRestrictionsLoaded ) { - $this->loadRestrictions(); - } - return $this->mRestrictions; + return MediaWikiServices::getInstance()->getRestrictionStore()->getAllRestrictions( $this ); } /** * Get the expiry time for the restriction against a given action * + * @deprecated since 1.37, use RestrictionStore::getRestrictionExpiry instead + * * @param string $action * @return string|bool 14-char timestamp, or 'infinity' if the page is protected forever * or not protected at all, or false if the action is not recognised. */ public function getRestrictionExpiry( $action ) { - if ( !$this->mRestrictionsLoaded ) { - $this->loadRestrictions(); - } - return $this->mRestrictionsExpiry[$action] ?? false; + return MediaWikiServices::getInstance()->getRestrictionStore()->getRestrictionExpiry( + $this, $action + ) ?? false; } /** * Returns cascading restrictions for the current article * + * @deprecated since 1.37, use RestrictionStore::areRestrictionsCascading instead + * * @return bool */ public function areRestrictionsCascading() { - if ( !$this->mRestrictionsLoaded ) { - $this->loadRestrictions(); - } - - return $this->mCascadeRestriction; + return MediaWikiServices::getInstance() + ->getRestrictionStore() + ->areRestrictionsCascading( $this ); } /** @@ -2873,6 +2675,8 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { * and page_restrictions table for this existing page. * Public for usage by LiquidThreads. * + * @deprecated since 1.37, use RestrictionStore::loadRestrictionsFromRows instead + * * @param stdClass[] $rows Array of db result objects * @param string|null $oldFashionedRestrictions Comma-separated set of permission keys * indicating who can move or edit the page from the page table, (pre 1.10) rows. @@ -2880,76 +2684,16 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { * Example: "edit=autoconfirmed,sysop:move=sysop" */ public function loadRestrictionsFromRows( $rows, $oldFashionedRestrictions = null ) { - // This function will only read rows from a table that we migrated away - // from before adding READ_LATEST support to loadRestrictions, so we - // don't need to support reading from DB_PRIMARY here. - $dbr = wfGetDB( DB_REPLICA ); - - $restrictionTypes = $this->getRestrictionTypes(); - - foreach ( $restrictionTypes as $type ) { - $this->mRestrictions[$type] = []; - $this->mRestrictionsExpiry[$type] = 'infinity'; - } - - $this->mCascadeRestriction = false; - - # Backwards-compatibility: also load the restrictions from the page record (old format). - if ( $oldFashionedRestrictions !== null ) { - $this->mOldRestrictions = $oldFashionedRestrictions; - } - - if ( $this->mOldRestrictions === false ) { - $linkCache = MediaWikiServices::getInstance()->getLinkCache(); - $linkCache->addLinkObj( $this ); # in case we already had an article ID - $this->mOldRestrictions = $linkCache->getGoodLinkFieldObj( $this, 'restrictions' ); - } - - if ( $this->mOldRestrictions != '' ) { - foreach ( explode( ':', trim( $this->mOldRestrictions ) ) as $restrict ) { - $temp = explode( '=', trim( $restrict ) ); - if ( count( $temp ) == 1 ) { - // old old format should be treated as edit/move restriction - $this->mRestrictions['edit'] = explode( ',', trim( $temp[0] ) ); - $this->mRestrictions['move'] = explode( ',', trim( $temp[0] ) ); - } else { - $restriction = trim( $temp[1] ); - if ( $restriction != '' ) { // some old entries are empty - $this->mRestrictions[$temp[0]] = explode( ',', $restriction ); - } - } - } - } - - if ( count( $rows ) ) { - # Current system - load second to make them override. - $now = wfTimestampNow(); - - # Cycle through all the restrictions. - foreach ( $rows as $row ) { - // Don't take care of restrictions types that aren't allowed - if ( !in_array( $row->pr_type, $restrictionTypes ) ) { - continue; - } - - $expiry = $dbr->decodeExpiry( $row->pr_expiry ); - - // Only apply the restrictions if they haven't expired! - if ( !$expiry || $expiry > $now ) { - $this->mRestrictionsExpiry[$row->pr_type] = $expiry; - $this->mRestrictions[$row->pr_type] = explode( ',', trim( $row->pr_level ) ); - - $this->mCascadeRestriction = $this->mCascadeRestriction || $row->pr_cascade; - } - } - } - - $this->mRestrictionsLoaded = true; + MediaWikiServices::getInstance()->getRestrictionStore()->loadRestrictionsFromRows( + $this, $rows, $oldFashionedRestrictions + ); } /** * Load restrictions from the page_restrictions table * + * @deprecated since 1.37, no public replacement + * * @param string|null $oldFashionedRestrictions Comma-separated set of permission keys * indicating who can move or edit the page from the page table, (pre 1.10) rows. * Edit and move sections are separated by a colon @@ -2958,79 +2702,18 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { * from the primary DB. */ public function loadRestrictions( $oldFashionedRestrictions = null, $flags = 0 ) { - $readLatest = DBAccessObjectUtils::hasFlags( $flags, self::READ_LATEST ); - if ( $this->mRestrictionsLoaded && !$readLatest ) { - return; - } - - $id = $this->getArticleID( $flags ); - if ( $id ) { - $fname = __METHOD__; - $loadRestrictionsFromDb = static function ( IDatabase $dbr ) use ( $fname, $id ) { - return iterator_to_array( - $dbr->select( - 'page_restrictions', - [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ], - [ 'pr_page' => $id ], - $fname - ) - ); - }; - - if ( $readLatest ) { - $dbr = wfGetDB( DB_PRIMARY ); - $rows = $loadRestrictionsFromDb( $dbr ); - } else { - $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); - $rows = $cache->getWithSetCallback( - // Page protections always leave a new null revision - $cache->makeKey( 'page-restrictions', 'v1', $id, $this->getLatestRevID() ), - $cache::TTL_DAY, - static function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) { - $dbr = wfGetDB( DB_REPLICA ); - - $setOpts += Database::getCacheSetOptions( $dbr ); - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); - if ( $lb->hasOrMadeRecentMasterChanges() ) { - // @TODO: cleanup Title cache and caller assumption mess in general - $ttl = WANObjectCache::TTL_UNCACHEABLE; - } - - return $loadRestrictionsFromDb( $dbr ); - } - ); - } - - $this->loadRestrictionsFromRows( $rows, $oldFashionedRestrictions ); - } else { - $title_protection = $this->getTitleProtectionInternal(); - - if ( $title_protection ) { - $now = wfTimestampNow(); - $expiry = wfGetDB( DB_REPLICA )->decodeExpiry( $title_protection['expiry'] ); - - if ( !$expiry || $expiry > $now ) { - // Apply the restrictions - $this->mRestrictionsExpiry['create'] = $expiry; - $this->mRestrictions['create'] = - explode( ',', trim( $title_protection['permission'] ) ); - } else { // Get rid of the old restrictions - $this->mTitleProtection = false; - } - } else { - $this->mRestrictionsExpiry['create'] = 'infinity'; - } - $this->mRestrictionsLoaded = true; - } + MediaWikiServices::getInstance()->getRestrictionStore()->loadRestrictions( $this, $flags, + $oldFashionedRestrictions ); } /** * Flush the protection cache in this object and force reload from the database. * This is used when updating protection from WikiPage::doUpdateRestrictions(). + * + * @deprecated since 1.37, now internal */ public function flushRestrictions() { - $this->mRestrictionsLoaded = false; - $this->mTitleProtection = null; + MediaWikiServices::getInstance()->getRestrictionStore()->flushRestrictions( $this ); } /** @@ -3344,9 +3027,6 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { } else { $this->mArticleID = (int)$id; } - $this->mRestrictionsLoaded = false; - $this->mRestrictions = []; - $this->mOldRestrictions = false; $this->mRedirect = null; $this->mLength = -1; $this->mLatestID = false; @@ -3358,6 +3038,7 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { $this->mIsBigDeletion = null; MediaWikiServices::getInstance()->getLinkCache()->clearLink( $this ); + MediaWikiServices::getInstance()->getRestrictionStore()->flushRestrictions( $this ); } public static function clearCaches() { diff --git a/tests/phpunit/includes/Permissions/PermissionManagerTest.php b/tests/phpunit/includes/Permissions/PermissionManagerTest.php index ba95f008126..86a77b8c439 100644 --- a/tests/phpunit/includes/Permissions/PermissionManagerTest.php +++ b/tests/phpunit/includes/Permissions/PermissionManagerTest.php @@ -10,6 +10,7 @@ use MediaWiki\Block\Restriction\ActionRestriction; use MediaWiki\Block\Restriction\NamespaceRestriction; use MediaWiki\Block\Restriction\PageRestriction; use MediaWiki\Block\SystemBlock; +use MediaWiki\Cache\CacheKeyHelper; use MediaWiki\MediaWikiServices; use MediaWiki\Revision\MutableRevisionRecord; use MediaWiki\Session\SessionId; @@ -240,13 +241,18 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->setTitle( NS_MAIN, "test page" ); $this->overrideUserPermissions( $this->user, [ "edit", "bogus", 'createpage' ] ); - $this->title->mCascadeSources = [ - Title::makeTitle( NS_MAIN, "Bogus" ), - Title::makeTitle( NS_MAIN, "UnBogus" ) - ]; - $this->title->mCascadingRestrictions = [ - "bogus" => [ 'bogus', "sysop", "protect", "" ] - ]; + $rs = MediaWikiServices::getInstance()->getRestrictionStore(); + $wrapper = TestingAccessWrapper::newFromObject( $rs ); + $wrapper->cache = [ CacheKeyHelper::getKeyForPage( $this->title ) => [ + 'cascade_sources' => [ + [ + Title::makeTitle( NS_MAIN, "Bogus" ), + Title::makeTitle( NS_MAIN, "UnBogus" ) + ], [ + "bogus" => [ 'bogus', "sysop", "protect", "" ], + ] + ], + ] ]; $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); @@ -275,24 +281,29 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $expectedPermErrors, $expectedUserCan ) { - $this->setTitle( $namespace, "test page" ); - $this->title->mTitleProtection['permission'] = ''; - $this->title->mTitleProtection['user'] = $this->user->getId(); - $this->title->mTitleProtection['expiry'] = 'infinity'; - $this->title->mTitleProtection['reason'] = 'test'; - $this->title->mCascadeRestriction = false; - $this->title->mRestrictionsLoaded = true; + $this->setTitle( $namespace, "Test page" ); - if ( isset( $titleOverrides['protectedPermission' ] ) ) { - $this->title->mTitleProtection['permission'] = $titleOverrides['protectedPermission']; - } - if ( isset( $titleOverrides['interwiki'] ) ) { - $this->title->mInterwiki = $titleOverrides['interwiki']; - } + $this->overrideUserPermissions( $this->user, $userPerms ); $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); - $this->overrideUserPermissions( $this->user, $userPerms ); + $rs = MediaWikiServices::getInstance()->getRestrictionStore(); + $wrapper = TestingAccessWrapper::newFromObject( $rs ); + $wrapper->cache = [ CacheKeyHelper::getKeyForPage( $this->title ) => [ + 'create_protection' => [ + 'permission' => $titleOverrides['protectedPermission'] ?? '', + 'user' => $this->user->getId(), + 'expiry' => 'infinity', + 'reason' => 'test', + ], + 'has_cascading' => false, + // XXX This is bogus, restrictions won't be empty if there's create protection + 'restrictions' => [], + ] ]; + + if ( isset( $titleOverrides['interwiki'] ) ) { + $this->title->mInterwiki = $titleOverrides['interwiki']; + } $this->assertEquals( $expectedPermErrors, diff --git a/tests/phpunit/includes/TitleTest.php b/tests/phpunit/includes/TitleTest.php index a025e7419db..7d1e223f96a 100644 --- a/tests/phpunit/includes/TitleTest.php +++ b/tests/phpunit/includes/TitleTest.php @@ -1,13 +1,16 @@ getExistingTestPage( 'UTest1' )->getTitle(); $title->loadRestrictions(); $this->assertTrue( $title->areRestrictionsLoaded() ); - $this->assertSame( - 'infinity', - $title->getRestrictionExpiry( 'create' ) - ); + $this->assertFalse( $title->getRestrictionExpiry( 'create' ), + "Existing page can't have create protection" ); + $this->assertSame( 'infinity', $title->getRestrictionExpiry( 'edit' ) ); $page = $this->getNonexistingTestPage( 'UTest1' ); $title = $page->getTitle(); $protectExpiry = wfTimestamp( TS_MW, time() + 10000 ); @@ -1429,7 +1431,7 @@ class TitleTest extends MediaWikiIntegrationTestCase { 'test', $this->getTestSysop()->getUser() ); - $title->mRestrictionsLoaded = false; + $title->flushRestrictions(); $title->loadRestrictions(); $this->assertSame( $title->getRestrictionExpiry( 'create' ), @@ -1484,17 +1486,133 @@ class TitleTest extends MediaWikiIntegrationTestCase { ); } + /** + * @dataProvider provideRestrictionStoreForwarding + * @covers Title::getFilteredRestrictionTypes + * @covers Title::getRestrictionTypes + * @covers Title::getTitleProtection + * @covers Title::deleteTitleProtection + * @covers Title::isSemiProtected + * @covers Title::isProtected + * @covers Title::isCascadeProtected + * @covers Title::areCascadeProtectionSourcesLoaded + * @covers Title::getCascadeProtectionSources + * @covers Title::areRestrictionsLoaded + * @covers Title::getRestrictions + * @covers Title::getAllRestrictions + * @covers Title::getRestrictionExpiry + * @covers Title::areRestrictionsCascading + * @covers Title::loadRestrictionsFromRows + * @covers Title::loadRestrictions + * @covers Title::flushRestrictions + */ + public function testRestrictionStoreForwarding( + string $method, array $params, $return, array $options = [] + ) { + $expectedParams = $options['expectedParams'] ?? $params; + + if ( isset( $options['static'] ) ) { + $callee = 'Title'; + } else { + $callee = $this->getExistingTestPage()->getTitle(); + $expectedParams = array_merge( [ $callee ], $expectedParams ); + } + + $mockRestrictionStore = $this->createMock( RestrictionStore::class ); + + $expectedMethod = $options['expectedMethod'] ?? $method; + + // Don't try to forward to a method that doesn't exist! + $this->assertIsCallable( [ $mockRestrictionStore, $expectedMethod ] ); + + $expectedCall = $mockRestrictionStore->expects( $this->once() ) + ->method( $expectedMethod ) + ->with( ...$expectedParams ); + if ( !isset( $options['void'] ) ) { + $expectedCall->willReturn( $return ); + } + + $mockRestrictionStore->expects( $this->never() ) + ->method( $this->anythingBut( $expectedMethod ) ); + + $this->setService( 'RestrictionStore', $mockRestrictionStore ); + + $options['expectedReturn'] = $options['expectedReturn'] ?? $return; + + $comparisonMethod = isset( $options['weakCompareReturn'] ) ? 'assertEquals' : 'assertSame'; + + $this->$comparisonMethod( $options['expectedReturn'], [ $callee, $method ]( ...$params ) ); + } + + public static function provideRestrictionStoreForwarding() { + $pageIdentity = PageIdentityValue::localIdentity( 144, NS_MAIN, 'Sample' ); + $title = Title::castFromPageIdentity( $pageIdentity ); + return [ + [ 'getFilteredRestrictionTypes', [ true ], [ 'abc' ], + [ 'static' => true, 'expectedMethod' => 'listAllRestrictionTypes' ] ], + [ 'getFilteredRestrictionTypes', [ false ], [ 'def' ], + [ 'static' => true, 'expectedMethod' => 'listAllRestrictionTypes' ] ], + [ 'getRestrictionTypes', [], [ 'ghi' ], + [ 'expectedMethod' => 'listApplicableRestrictionTypes' ] ], + [ 'getTitleProtection', [], [ 'jkl' ], [ 'expectedMethod' => 'getCreateProtection' ] ], + [ 'getTitleProtection', [], null, + [ 'expectedMethod' => 'getCreateProtection', 'expectedReturn' => false ] ], + [ 'deleteTitleProtection', [], null, + [ 'expectedMethod' => 'deleteCreateProtection', 'void' => true ] ], + [ 'isSemiProtected', [ 'phlebotomize' ], true ], + [ 'isSemiProtected', [ 'splecotomize' ], false ], + [ 'isProtected', [ 'strezotomize' ], true ], + [ 'isProtected', [ 'chrelotomize' ], false ], + [ 'isCascadeProtected', [], true ], + [ 'isCascadeProtected', [], false ], + [ 'areCascadeProtectionSourcesLoaded', [ true ], true, [ 'expectedParams' => [] ] ], + [ 'areCascadeProtectionSourcesLoaded', [ true ], false, [ 'expectedParams' => [] ] ], + [ 'areCascadeProtectionSourcesLoaded', [ false ], true, [ 'expectedParams' => [] ] ], + [ 'areCascadeProtectionSourcesLoaded', [ false ], false, [ 'expectedParams' => [] ] ], + [ 'getCascadeProtectionSources', [], [ [ $pageIdentity ], [ 'mno' ] ], + [ 'expectedReturn' => [ [ $title ], [ 'mno' ] ], 'weakCompareReturn' => true ] ], + [ 'getCascadeProtectionSources', [], [ [], [] ], + [ 'expectedReturn' => [ false, [] ] ] ], + [ 'getCascadeProtectionSources', [ true ], [ [ $pageIdentity ], [ 'mno' ] ], + [ 'expectedParams' => [], 'expectedReturn' => [ [ $title ], [ 'mno' ] ], + 'weakCompareReturn' => true ] ], + [ 'getCascadeProtectionSources', [ true ], [ [], [] ], + [ 'expectedParams' => [], 'expectedReturn' => [ false, [] ] ] ], + [ 'getCascadeProtectionSources', [ false ], false, + [ 'expectedMethod' => 'isCascadeProtected', 'expectedParams' => [], + 'expectedReturn' => [ false, [] ] ] ], + [ 'getCascadeProtectionSources', [ false ], true, + [ 'expectedMethod' => 'isCascadeProtected', 'expectedParams' => [], + 'expectedReturn' => [ true, [] ] ] ], + [ 'areRestrictionsLoaded', [], true ], + [ 'areRestrictionsLoaded', [], false ], + [ 'getRestrictions', [ 'stu' ], [ 'vwx' ] ], + [ 'getAllRestrictions', [], [ 'yza' ] ], + [ 'getRestrictionExpiry', [ 'bcd' ], 'efg' ], + [ 'getRestrictionExpiry', [ 'hij' ], null, [ 'expectedReturn' => false ] ], + [ 'areRestrictionsCascading', [], true ], + [ 'areRestrictionsCascading', [], false ], + [ 'loadRestrictionsFromRows', [ [ 'hij' ], 'klm' ], null, [ 'void' => true ] ], + [ 'loadRestrictions', [ 'nop', 123 ], null, + [ 'void' => true, 'expectedParams' => [ 123, 'nop' ] ] ], + [ 'flushRestrictions', [], null, [ 'void' => true ] ], + ]; + } + /** * @covers Title::getRestrictions */ public function testGetRestrictions() { $title = $this->getExistingTestPage( 'UTest1' )->getTitle(); - $title->mRestrictions = [ - 'a' => [ 'sysop' ], - 'b' => [ 'sysop' ], - 'c' => [ 'sysop' ] - ]; - $title->mRestrictionsLoaded = true; + $rs = MediaWikiServices::getInstance()->getRestrictionStore(); + $wrapper = TestingAccessWrapper::newFromObject( $rs ); + $wrapper->cache = [ CacheKeyHelper::getKeyForPage( $title ) => [ + 'restrictions' => [ + 'a' => [ 'sysop' ], + 'b' => [ 'sysop' ], + 'c' => [ 'sysop' ] + ], + ] ]; $this->assertArrayEquals( [ 'sysop' ], $title->getRestrictions( 'a' ) ); $this->assertArrayEquals( [], $title->getRestrictions( 'error' ) ); // TODO: maybe test if loadRestrictionsFromRows() is called? @@ -1504,15 +1622,19 @@ class TitleTest extends MediaWikiIntegrationTestCase { * @covers Title::getAllRestrictions */ public function testGetAllRestrictions() { - $title = $this->getExistingTestPage( 'UTest1' )->getTitle(); - $title->mRestrictions = [ + $restrictions = [ 'a' => [ 'sysop' ], 'b' => [ 'sysop' ], - 'c' => [ 'sysop' ] + 'c' => [ 'sysop' ], ]; - $title->mRestrictionsLoaded = true; + $title = $this->getExistingTestPage( 'UTest1' )->getTitle(); + $rs = MediaWikiServices::getInstance()->getRestrictionStore(); + $wrapper = TestingAccessWrapper::newFromObject( $rs ); + $wrapper->cache = [ CacheKeyHelper::getKeyForPage( $title ) => [ + 'restrictions' => $restrictions + ] ]; $this->assertArrayEquals( - $title->mRestrictions, + $restrictions, $title->getAllRestrictions() ); } @@ -1522,13 +1644,15 @@ class TitleTest extends MediaWikiIntegrationTestCase { */ public function testGetRestrictionExpiry() { $title = $this->getExistingTestPage( 'UTest1' )->getTitle(); - $reflection = new ReflectionClass( $title ); - $reflection_property = $reflection->getProperty( 'mRestrictionsExpiry' ); - $reflection_property->setAccessible( true ); - $reflection_property->setValue( $title, [ - 'a' => 'infinity', 'b' => 'infinity', 'c' => 'infinity' - ] ); - $title->mRestrictionsLoaded = true; + $rs = MediaWikiServices::getInstance()->getRestrictionStore(); + $wrapper = TestingAccessWrapper::newFromObject( $rs ); + $wrapper->cache = [ CacheKeyHelper::getKeyForPage( $title ) => [ + 'expiry' => [ + 'a' => 'infinity', 'b' => 'infinity', 'c' => 'infinity' + ], + // XXX This is bogus, restrictions will never be empty when expiry is not + 'restrictions' => [], + ] ]; $this->assertSame( 'infinity', $title->getRestrictionExpiry( 'a' ) ); $this->assertArrayEquals( [], $title->getRestrictions( 'error' ) ); } @@ -1538,7 +1662,6 @@ class TitleTest extends MediaWikiIntegrationTestCase { */ public function testGetTitleProtection() { $title = $this->getNonexistingTestPage( 'UTest1' )->getTitle(); - $title->mTitleProtection = false; $this->assertFalse( $title->getTitleProtection() ); } @@ -1547,17 +1670,19 @@ class TitleTest extends MediaWikiIntegrationTestCase { */ public function testIsSemiProtected() { $title = $this->getExistingTestPage( 'UTest1' )->getTitle(); - $title->mRestrictions = [ - 'edit' => [ 'sysop' ] - ]; $this->setMwGlobals( [ 'wgSemiprotectedRestrictionLevels' => [ 'autoconfirmed' ], 'wgRestrictionLevels' => [ '', 'autoconfirmed', 'sysop' ] ] ); + $rs = MediaWikiServices::getInstance()->getRestrictionStore(); + $wrapper = TestingAccessWrapper::newFromObject( $rs ); + $wrapper->cache = [ CacheKeyHelper::getKeyForPage( $title ) => [ + 'restrictions' => [ 'edit' => [ 'sysop' ] ], + ] ]; $this->assertFalse( $title->isSemiProtected( 'edit' ) ); - $title->mRestrictions = [ - 'edit' => [ 'autoconfirmed' ] - ]; + $wrapper->cache = [ CacheKeyHelper::getKeyForPage( $title ) => [ + 'restrictions' => [ 'edit' => [ 'autoconfirmed' ] ], + ] ]; $this->assertTrue( $title->isSemiProtected( 'edit' ) ); } @@ -1578,13 +1703,15 @@ class TitleTest extends MediaWikiIntegrationTestCase { 'wgRestrictionLevels' => [ '', 'autoconfirmed', 'sysop' ], 'wgRestrictionTypes' => [ 'create', 'edit', 'move', 'upload' ] ] ); - $title->mRestrictions = [ - 'edit' => [ 'sysop' ] - ]; - $this->assertFalse( $title->isProtected( 'edit' ) ); - $title->mRestrictions = [ - 'edit' => [ 'test' ] - ]; + $rs = MediaWikiServices::getInstance()->getRestrictionStore(); + $wrapper = TestingAccessWrapper::newFromObject( $rs ); + $wrapper->cache = [ CacheKeyHelper::getKeyForPage( $title ) => [ + 'restrictions' => [ 'edit' => [ 'sysop' ] ], + ] ]; + $this->assertTrue( $title->isProtected( 'edit' ) ); + $wrapper->cache = [ CacheKeyHelper::getKeyForPage( $title ) => [ + 'restrictions' => [ 'edit' => [ 'test' ] ], + ] ]; $this->assertFalse( $title->isProtected( 'edit' ) ); } @@ -1616,14 +1743,15 @@ class TitleTest extends MediaWikiIntegrationTestCase { public function testIsCascadeProtected() { $page = $this->getExistingTestPage( 'UTest1' ); $title = $page->getTitle(); - $reflection = new ReflectionClass( $title ); - $reflection_property = $reflection->getProperty( 'mHasCascadingRestrictions' ); - $reflection_property->setAccessible( true ); - $reflection_property->setValue( $title, true ); + $rs = MediaWikiServices::getInstance()->getRestrictionStore(); + $wrapper = TestingAccessWrapper::newFromObject( $rs ); + $wrapper->cache = [ CacheKeyHelper::getKeyForPage( $title ) => [ + 'has_cascading' => true, + ] ]; $this->assertTrue( $title->isCascadeProtected() ); - $reflection_property->setValue( $title, null ); + $wrapper->cache = []; $this->assertFalse( $title->isCascadeProtected() ); - $reflection_property->setValue( $title, null ); + $wrapper->cache = []; $cascade = 1; $anotherPage = $this->getExistingTestPage( 'UTest2' ); $anotherPage->doUserEditContent( @@ -1643,6 +1771,7 @@ class TitleTest extends MediaWikiIntegrationTestCase { /** * @covers Title::getCascadeProtectionSources + * @group Broken */ public function testGetCascadeProtectionSources() { $page = $this->getExistingTestPage( 'UTest1' ); diff --git a/tests/phpunit/integration/includes/Permissions/RestrictionStoreTest.php b/tests/phpunit/integration/includes/Permissions/RestrictionStoreTest.php new file mode 100644 index 00000000000..a0f010a2b9c --- /dev/null +++ b/tests/phpunit/integration/includes/Permissions/RestrictionStoreTest.php @@ -0,0 +1,177 @@ +getServiceContainer(); + $this->wanCache = $services->getMainWANObjectCache(); + $this->loadBalancer = $services->getDBLoadBalancer(); + $this->linkCache = $services->getLinkCache(); + $this->commentStore = $services->getCommentStore(); + $this->hookContainer = $services->getHookContainer(); + $this->pageStore = $services->getPageStore(); + } + + public function addDBDataOnce() { + self::$testPageRestrictionCascade = + $this->insertPage( 'Template:RestrictionStoreTestA', 'wooooooo' ); + $this->insertPage( 'Template:RestrictionStoreTestB', '{{RestrictionStoreTestA}}' ); + + self::$testPageRestrictionSource = + $this->insertPage( 'RestrictionStoreTest_1', '{{RestrictionStoreTestB}}' ); + + $this->updateRestrictions( self::$testPageRestrictionSource['title'], [ 'edit' => 'sysop' ] ); + } + + private function newRestrictionStore( array $options = [] ) { + return new RestrictionStore( + new ServiceOptions( RestrictionStore::CONSTRUCTOR_OPTIONS, $options + [ + 'NamespaceProtection' => [], + 'RestrictionLevels' => [ '', 'autoconfirmed', 'sysop' ], + 'RestrictionTypes' => self::DEFAULT_RESTRICTION_TYPES, + 'SemiprotectedRestrictionLevels' => [ 'autoconfirmed' ], + ] ), + $this->wanCache, + $this->loadBalancer, + $this->linkCache, + $this->commentStore, + $this->hookContainer, + $this->pageStore + ); + } + + private function updateRestrictions( $page, array $limit, int $cascade = 1 ) { + $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $page ) + ->doUpdateRestrictions( + $limit, + [], + $cascade, + 'test', + $this->getTestSysop()->getUser() + ); + } + + /** + * @covers ::getCascadeProtectionSources + * @covers ::getCascadeProtectionSourcesInternal + */ + public function testGetCascadeProtectionSources() { + $page = self::$testPageRestrictionCascade['title']; + $pageSource = self::$testPageRestrictionSource['title']; + + [ $sources, $restrictions ] = $this->newRestrictionStore() + ->getCascadeProtectionSources( $page ); + $this->assertCount( 1, $sources ); + $this->assertTrue( $pageSource->isSamePageAs( $sources[$pageSource->getId()] ) ); + $this->assertArrayEquals( [ 'edit' => [ 'sysop' ] ], $restrictions ); + + [ $sources, $restrictions ] = $this->newRestrictionStore() + ->getCascadeProtectionSources( $pageSource ); + $this->assertCount( 0, $sources ); + $this->assertCount( 0, $restrictions ); + } + + /** + * @covers ::loadRestrictions + * @dataProvider provideLoadRestrictions + */ + public function testLoadRestrictions( $page, $expectedCacheSubmap, ?array $restrictions = null ) { + $cacheKey = CacheKeyHelper::getKeyForPage( $page ); + + if ( $restrictions ) { + $this->updateRestrictions( $page, $restrictions ); + } + + $restrictionStore = $this->newRestrictionStore(); + $restrictionStore->loadRestrictions( $page ); + $wrapper = TestingAccessWrapper::newFromObject( $restrictionStore ); + $this->assertArraySubmapSame( + $expectedCacheSubmap, + $wrapper->cache[$cacheKey] + ); + } + + public function provideLoadRestrictions(): array { + return [ + 'Regular page with restrictions' => [ + Title::newFromText( 'RestrictionStoreTest_1' ), + [ 'restrictions' => [ 'edit' => [ 'sysop' ] ] ] + ], + 'Nonexistent page' => [ + PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ), + [ 'create_protection' => null ] + ], + 'Nonexistent page with restrictions' => [ + PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ), + [ 'create_protection' => [ 'expiry' => 'infinity' ] ], + [ 'create' => 'sysop' ] + ], + ]; + } + + /** + * @covers ::loadRestrictions + */ + public function testLoadRestrictions_latest() { + $pageSource = self::$testPageRestrictionSource['title']; + $cacheKey = CacheKeyHelper::getKeyForPage( $pageSource ); + + $restrictionStore = $this->newRestrictionStore(); + $restrictionStore->loadRestrictions( $pageSource ); + $wrapper = TestingAccessWrapper::newFromObject( $restrictionStore ); + $this->assertArraySubmapSame( + [ 'restrictions' => [ 'edit' => [ 'sysop' ] ] ], + $wrapper->cache[$cacheKey] + ); + + $this->updateRestrictions( $pageSource, [ 'move' => 'sysop' ] ); + $restrictionStore->loadRestrictions( $pageSource, IDBAccessObject::READ_LATEST ); + $this->assertArraySubmapSame( + [ 'restrictions' => [ 'move' => [ 'sysop' ] ] ], + $wrapper->cache[$cacheKey] + ); + } +} diff --git a/tests/phpunit/mocks/DummyServicesTrait.php b/tests/phpunit/mocks/DummyServicesTrait.php index 11d538e7c0a..c7864423546 100644 --- a/tests/phpunit/mocks/DummyServicesTrait.php +++ b/tests/phpunit/mocks/DummyServicesTrait.php @@ -21,6 +21,7 @@ namespace MediaWiki\Tests\Unit; +use CommentStore; use ConfiguredReadOnlyMode; use GenderCache; use Interwiki; @@ -493,4 +494,19 @@ trait DummyServicesTrait { } ); return $mock; } + + private function getDummyCommentStore(): CommentStore { + $mockLang = $this->createNoOpMock( Language::class, + [ 'truncateForVisual', 'truncateForDatabase' ] ); + $mockLang->method( $this->logicalOr( 'truncateForDatabase', 'truncateForVisual' ) ) + ->willReturnCallback( + static function ( string $text, int $limit ): string { + if ( strlen( $text ) > $limit - 3 ) { + return substr( $text, 0, $limit - 3 ) . '...'; + } + return $text; + } + ); + return new CommentStore( $mockLang, MIGRATION_NEW ); + } } diff --git a/tests/phpunit/unit/includes/Permissions/RestrictionStoreTest.php b/tests/phpunit/unit/includes/Permissions/RestrictionStoreTest.php new file mode 100644 index 00000000000..16763e200c5 --- /dev/null +++ b/tests/phpunit/unit/includes/Permissions/RestrictionStoreTest.php @@ -0,0 +1,979 @@ + [ + * 'update' => callback, # to be called exactly once + * 'delete' => [ callback, 2 ], # to be called exactly twice + * 'select' => [ callback, -1 ], # may be called 0 or more times + * ], + * ] + * @return ILoadBalancer + */ + private function newMockLoadBalancer( array $expectedCalls = [] ): ILoadBalancer { + if ( !isset( $expectedCalls[DB_REPLICA] ) ) { + $expectedCalls[DB_REPLICA] = []; + } + $expectedCalls[DB_REPLICA]['decodeExpiry'] = [ + static function ( string $expiry ): string { + return $expiry; + }, + -1 + ]; + + $dbs = []; + foreach ( $expectedCalls as $index => $calls ) { + $dbs[$index] = $this->createNoOpMock( IDatabase::class, array_keys( $calls ) ); + foreach ( $calls as $method => $callback ) { + $count = 1; + if ( is_array( $callback ) ) { + [ $callback, $count ] = $callback; + } + $dbs[$index]->expects( $count < 0 ? $this->any() : $this->exactly( $count ) ) + ->method( $method )->willReturnCallback( $callback ); + } + } + + $lb = $this->createMock( ILoadBalancer::class, [ 'getConnectionRef' ] ); + $lb->method( 'getConnectionRef' )->willReturnCallback( + function ( int $index ) use ( $dbs ): IDatabase { + $this->assertArrayHasKey( $index, $dbs ); + return $dbs[$index]; + } + ); + + return $lb; + } + + private function newRestrictionStore( array $options = [] ): RestrictionStore { + return new RestrictionStore( + new ServiceOptions( RestrictionStore::CONSTRUCTOR_OPTIONS, $options + [ + 'NamespaceProtection' => [], + 'RestrictionLevels' => [ '', 'autoconfirmed', 'sysop' ], + 'RestrictionTypes' => self::DEFAULT_RESTRICTION_TYPES, + 'SemiprotectedRestrictionLevels' => [ 'autoconfirmed' ], + ] ), + $this->createNoOpMock( WANObjectCache::class ), + $this->newMockLoadBalancer( $options['db'] ?? [] ), + // @todo test that these calls work correctly + $this->createNoOpMock( LinkCache::class, [ 'addLinkObj', 'getGoodLinkFieldObj' ] ), + $this->getDummyCommentStore(), + $this->createHookContainer( isset( $options['hookFn'] ) + ? [ 'TitleGetRestrictionTypes' => $options['hookFn'] ] + : [] ), + $this->createNoOpMock( PageStore::class ) + ); + } + + private static function newImproperPageIdentity( + int $ns, string $dbKey, $wikiId = PageIdentity::LOCAL + ): PageIdentity { + // PageIdentityValue doesn't allow negative namespaces, and Title::exists accesses services + // for hooks, so we need another solution to unit-test PageIdentity objects with negative + // namespaces (until they cease to exist). + return new class( $ns, $dbKey, $wikiId ) extends PageReferenceValue implements PageIdentity { + public function getId( $wikiId = PageIdentity::LOCAL ): int { + throw new RuntimeException; + } + + public function canExist(): bool { + return false; + } + + public function exists(): bool { + return false; + } + }; + } + + /** + * @covers :: + * @dataProvider provideNonLocalPage + */ + public function testNonLocalPage( string $method, ...$extraArgs ) { + $this->expectException( PreconditionException::class ); + $this->expectExceptionMessage( 'otherwiki' ); + + $obj = $this->newRestrictionStore(); + $page = new PageIdentityValue( 1, NS_MAIN, 'X', 'otherwiki' ); + $obj->$method( $page, ...$extraArgs ); + } + + public function provideNonLocalPage() { + // We programmatically get all public methods whose first parameter is a PageIdentity. This + // way we'll make sure to include any new methods that are added in the future. + $ret = []; + $methods = ( new ReflectionClass( RestrictionStore::class ) ) + ->getMethods( ReflectionMethod::IS_PUBLIC ); + foreach ( $methods as $method ) { + $params = $method->getParameters(); + if ( !$params[0]->hasType() || $params[0]->getType()->getName() !== PageIdentity::class ) { + continue; + } + $ret[$method->getName()] = [ $method->getName() ]; + + foreach ( array_slice( $params, 1 ) as $param ) { + // Extra required arguments + if ( $param->isOptional() ) { + break; + } + switch ( $param->getType()->getName() ) { + case 'string': + $ret[$method->getName()][] = 'x'; + break; + case 'array': + $ret[$method->getName()][] = []; + break; + default: + throw new UnexpectedValueException( + "{$param->getType()->getName} type not supported" ); + } + } + } + + return $ret; + } + + /** + * @covers ::getRestrictions + * @dataProvider provideGetRestrictions + */ + public function testGetRestrictions( + array $expected, + PageIdentity $page, + string $action, + ?array $rowsToLoad, + array $options = [] + ): void { + $obj = $this->newRestrictionStore( $options ); + if ( is_array( $rowsToLoad ) ) { + $obj->loadRestrictionsFromRows( $page, $rowsToLoad ); + } + $this->assertSame( $expected, $obj->getRestrictions( $page, $action ) ); + } + + public function provideGetRestrictions(): array { + $all = $this->provideGetAllRestrictions(); + $ret = []; + + foreach ( $all as $name => $arr ) { + [ $expected, $page ] = $arr; + + $actions = array_merge( self::DEFAULT_RESTRICTION_TYPES, [ 'vaporize' ], + array_keys( $expected ) ); + foreach ( $actions as $action ) { + $ret["$name ($action)"] = + array_merge( + [ $expected[$action] ?? [], $page, $action ], + array_slice( $arr, 2 ) + ); + } + } + + return $ret; + } + + /** + * @covers ::__construct + * @covers ::getAllRestrictions + * @covers ::loadRestrictions + * @covers ::loadRestrictionsFromRows + * @dataProvider provideGetAllRestrictions + */ + public function testGetAllRestrictions( + array $expected, PageIdentity $page, ?array $rowsToLoad, array $options = [] + ): void { + $obj = $this->newRestrictionStore( $options ); + if ( is_array( $rowsToLoad ) ) { + $obj->loadRestrictionsFromRows( $page, $rowsToLoad ); + } + $this->assertSame( $expected, $obj->getAllRestrictions( $page ) ); + } + + public function provideGetAllRestrictions(): array { + return [ + 'Special page' => [ + [], + self::newImproperPageIdentity( NS_SPECIAL, 'X' ), + null, + ], + 'Media page' => [ + [], + self::newImproperPageIdentity( NS_MEDIA, 'X' ), + null, + ], + 'Simple existing unprotected page' => [ + [ 'edit' => [], 'move' => [] ], + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + [], + ], + 'Simple existing protected page' => [ + [ 'edit' => [ 'sysop', 'bureaucrat' ], 'move' => [ 'sysop' ] ], + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop,bureaucrat', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + ], + ], + 'Protection type not allowed' => [ + [ 'edit' => [ 'sysop' ] ], + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + ], + [ 'RestrictionTypes' => [ 'create', 'edit', 'upload' ] ], + ], + 'Expired protection' => [ + [ 'edit' => [], 'move' => [ 'sysop' ] ], + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => '20200101000000', 'pr_cascade' => 0 ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + ], + ], + ]; + } + + /** + * @covers ::getRestrictionExpiry + * @dataProvider provideGetRestrictionExpiry + */ + public function testGetRestrictionExpiry( + ?string $expected, + PageIdentity $page, + string $action, + ?array $rowsToLoad, + array $options = [] + ): void { + $obj = $this->newRestrictionStore( $options ); + if ( is_array( $rowsToLoad ) ) { + $obj->loadRestrictionsFromRows( $page, $rowsToLoad ); + } + $this->assertSame( $expected, $obj->getRestrictionExpiry( $page, $action ) ); + } + + public function provideGetRestrictionExpiry(): array { + return [ + 'Special page' => [ + null, + self::newImproperPageIdentity( NS_SPECIAL, 'X' ), + 'edit', + null, + ], + 'Media page' => [ + null, + self::newImproperPageIdentity( NS_MEDIA, 'X' ), + 'edit', + null, + ], + 'Simple existing unprotected page' => [ + 'infinity', + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + 'edit', + [], + ], + 'Simple existing protected page (edit)' => [ + '20760101000000', + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + 'edit', + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => '20760101000000', 'pr_cascade' => 0 ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + ], + ], + 'Simple existing protected page (move)' => [ + '20670101000000', + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + 'move', + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => '20670101000000', 'pr_cascade' => 0 ], + ], + ], + 'Simple existing protected page (unrecognized)' => [ + null, + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + 'unrecognized', + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + ], + ], + 'Simple existing expired protected page (edit)' => [ + 'infinity', + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + 'edit', + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => '20160101000000', 'pr_cascade' => 0 ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + ], + ], + 'Simple existing expired protected page (move)' => [ + 'infinity', + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + 'move', + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => '20170101000000', 'pr_cascade' => 0 ], + ], + ], + 'Simple existing expired protected page (unrecognized)' => [ + null, + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + 'unrecognized', + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => '20170101000000', 'pr_cascade' => 0 ], + (object)[ 'pr_type' => 'unrecognized', 'pr_level' => 'sysop', + 'pr_expiry' => '20170101000000', 'pr_cascade' => 0 ], + ], + ], + ]; + } + + /** + * @covers ::getCreateProtection + * @covers ::getCreateProtectionInternal + * @dataProvider provideGetCreateProtection + */ + public function testGetCreateProtection( + ?array $expected, PageIdentity $page, $return, array $options = [] + ): void { + if ( $page->canExist() && !$page->exists() ) { + $options['db'] = [ DB_REPLICA => [ 'selectRow' => + function ( + $table, $vars, $conds, string $fname, $options = [], $join_conds = [] + ) use ( $page, $return ) { + $options = (array)$options; + $options['LIMIT'] = 1; + $db = new DatabaseTestHelper( __CLASS__ ); + $sql = trim( preg_replace( '/\s+/', ' ', $db->selectSQLText( + $table, $vars, $conds, $fname, $options, $join_conds ) ) ); + $this->assertSame( + 'SELECT pt_user,pt_expiry,pt_create_perm,comment_pt_reason.comment_text ' . + 'AS pt_reason_text,comment_pt_reason.comment_data AS pt_reason_data,' . + 'comment_pt_reason.comment_id AS pt_reason_cid ' . + 'FROM protected_titles JOIN comment comment_pt_reason ON ' . + '((comment_pt_reason.comment_id = pt_reason_id)) ' . + "WHERE pt_namespace = {$page->getNamespace()} AND " . + "pt_title = '{$page->getDBkey()}' LIMIT 1", + $sql ); + + return is_array( $return ) ? (object)$return : $return; + } + ] ]; + } + $obj = $this->newRestrictionStore( $options ); + $this->assertSame( $expected, $obj->getCreateProtection( $page ) ); + } + + public function provideGetCreateProtection(): array { + $ret = [ + 'Special page' => [ null, self::newImproperPageIdentity( NS_SPECIAL, 'X' ), null ], + 'Media page' => [ null, self::newImproperPageIdentity( NS_MEDIA, 'X' ), null ], + 'Existing page' => [ null, PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), null ], + 'Unprotected' => [ null, PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ), false ], + ]; + $protectedTests = [ + 'sysop' => 'editprotected', + 'autoconfirmed' => 'editsemiprotected', + 'editprotected' => 'editprotected', + 'editsemiprotected' => 'editsemiprotected', + 'custom' => 'custom', + ]; + foreach ( $protectedTests as $db => $returned ) { + $ret["Protected ($db)"] = [ + [ + 'user' => 123, + 'expiry' => 'infinity', + 'permission' => $returned, + 'reason' => 'reason', + ], + PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ), + [ + 'pt_user' => 123, + 'pt_expiry' => 'infinity', + 'pt_create_perm' => $db, + 'pt_reason_id' => 456, + 'pt_reason_data' => '{}', + 'pt_reason_text' => 'reason', + ], + ]; + } + + return $ret; + } + + /** + * @covers ::deleteCreateProtection + * @dataProvider provideDeleteCreateProtection + */ + public function testDeleteCreateProtection( PageIdentity $page ): void { + $obj = $this->newRestrictionStore( [ 'db' => [ DB_PRIMARY => [ 'delete' => + function ( string $table, array $where, string $method ) use ( $page ): bool { + $this->assertSame( 'protected_titles', $table ); + $this->assertSame( + [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ], + $where + ); + return true; + } + ] ] ] ); + + $obj->deleteCreateProtection( $page ); + } + + public function provideDeleteCreateProtection(): array { + return [ + // Most of these don't actually make sense, but test current behavior regardless. + 'Special page' => [ self::newImproperPageIdentity( NS_SPECIAL, 'X' ) ], + 'Media page' => [ self::newImproperPageIdentity( NS_MEDIA, 'X' ) ], + 'Nonexistent page' => [ PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ) ], + 'Existing page' => [ PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ) ], + ]; + } + + /** + * @covers ::isSemiProtected + * @dataProvider provideIsSemiProtected + */ + public function testIsSemiProtected( + bool $expected, ?array $rowsToLoad, array $options = [] + ): void { + $page = $options['page'] ?? PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ); + $obj = $this->newRestrictionStore( $options ); + if ( is_array( $rowsToLoad ) ) { + $obj->loadRestrictionsFromRows( $page, $rowsToLoad ); + } + $this->assertSame( $expected, $obj->isSemiProtected( $page, 'edit' ) ); + } + + public function provideIsSemiProtected(): array { + return [ + 'Special page' => [ false, null, + [ 'page' => self::newImproperPageIdentity( NS_SPECIAL, 'X' ) ] ], + 'Media page' => [ false, null, + [ 'page' => self::newImproperPageIdentity( NS_MEDIA, 'X' ) ] ], + 'Unprotected page' => [ + false, + [ (object)[ 'pr_type' => 'move', 'pr_level' => 'autoconfirmed', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + ], + 'Semiprotected page' => [ + true, + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], + ], + ], + 'Fully protected page' => [ + false, + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'autoconfirmed', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], + ], + ], + 'No semiprotection configured' => [ + false, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + [ 'SemiprotectedRestrictionLevels' => [] ], + ], + 'Config with editsemiprotected' => [ + true, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + [ 'SemiprotectedRestrictionLevels' => [ 'editsemiprotected' ] ], + ], + 'Data with editsemiprotected' => [ + true, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'editsemiprotected', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + ], + 'Config and data with editsemiprotected' => [ + true, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'editsemiprotected', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + [ 'SemiprotectedRestrictionLevels' => [ 'editsemiprotected' ] ], + ], + 'Semiprotection plus other protection level' => [ + false, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed,superman', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + [ 'RestrictionLevels' => [ '', 'autoconfirmed', 'sysop', 'superman' ] ], + ], + 'Two semiprotections' => [ + true, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed,superman', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + [ 'RestrictionLevels' => [ '', 'autoconfirmed', 'sysop', 'superman' ], + 'SemiprotectedRestrictionLevels' => [ 'autoconfirmed', 'superman' ] ], + ], + ]; + } + + /** + * @covers ::isProtected + * @dataProvider provideIsProtected + */ + public function testIsProtected( + bool $expected, ?array $rowsToLoad, array $options = [] + ): void { + $page = $options['page'] ?? PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ); + $action = $options['action'] ?? ''; + $obj = $this->newRestrictionStore( $options ); + if ( is_array( $rowsToLoad ) ) { + $obj->loadRestrictionsFromRows( $page, $rowsToLoad ); + } + $this->assertSame( $expected, $obj->isProtected( $page, $action ) ); + } + + public function provideIsProtected(): array { + return [ + 'Special page' => [ true, null, + [ 'page' => self::newImproperPageIdentity( NS_SPECIAL, 'X' ) ] ], + 'Media page' => [ false, null, + [ 'page' => self::newImproperPageIdentity( NS_MEDIA, 'X' ) ] ], + 'Unprotected page' => [ false, [] ], + 'Semiprotected page' => [ + true, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'autoconfirmed', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + ], + 'Fully protected page' => [ + true, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + ], + 'Protected against empty string' => [ + false, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => '', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + ], + 'Unrecognized protection' => [ + false, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'unrecognized', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + ], + 'Unrecognized plus recognized protection' => [ + true, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop,unrecognized', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + ], + + 'Check unrecognized protection type' => [ + false, + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], + (object)[ 'pr_type' => 'unrecognized', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], + ], + [ 'action' => 'unrecognized' ], + ], + 'Check custom protection type' => [ + true, + [ (object)[ 'pr_type' => 'custom', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], ], + [ 'action' => 'custom', 'RestrictionTypes' => + array_merge( self::DEFAULT_RESTRICTION_TYPES, [ 'custom' ] ) ], + ], + 'Check custom protection level' => [ + true, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'custom', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], ], + [ 'action' => 'edit', 'RestrictionLevels' => + [ '', 'autoconfirmed', 'sysop', 'custom' ] ], + ], + + 'Check edit protection of edit-protected page' => [ + true, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + [ 'action' => 'edit' ], + ], + 'Check move protection of edit-protected page' => [ + false, + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + [ 'action' => 'move' ], + ], + 'Check move protection of move-protected page' => [ + true, + [ (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + [ 'action' => 'move' ], + ], + 'Check edit protection of move-protected page' => [ + false, + [ (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ] ], + [ 'action' => 'edit' ], + ], + 'Check edit protection of edit- and move-protected page' => [ + true, + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], + ], + [ 'action' => 'edit' ], + ], + 'Check move protection of edit- and move-protected page' => [ + true, + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => '0' ], + ], + [ 'action' => 'move' ], + ], + ]; + } + + /** + * @covers ::listApplicableRestrictionTypes + * @dataProvider provideListApplicableRestrictionTypes + */ + public function testListApplicableRestrictionTypes( + array $expected, PageIdentity $page, array $options = [] + ): void { + $obj = $this->newRestrictionStore( $options ); + + $this->assertSame( $expected, $obj->listApplicableRestrictionTypes( $page ) ); + } + + public function provideListApplicableRestrictionTypes(): array { + $expandedRestrictions = array_merge( self::DEFAULT_RESTRICTION_TYPES, [ 'liquify' ] ); + return [ + 'Special page' => [ + [], + self::newImproperPageIdentity( NS_SPECIAL, 'X' ), + ], + 'Media page' => [ + [], + self::newImproperPageIdentity( NS_MEDIA, 'X' ), + ], + 'Nonexistent page' => [ + [ 'create' ], + PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ), + ], + 'Existing page' => [ + [ 'edit', 'move' ], + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + ], + 'Nonexistent file' => [ + [ 'create', 'upload' ], + PageIdentityValue::localIdentity( 0, NS_FILE, 'X' ), + ], + 'Existing file' => [ + [ 'edit', 'move', 'upload' ], + PageIdentityValue::localIdentity( 1, NS_FILE, 'X' ), + ], + + 'Nonexistent page with no create' => [ + [], + PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ), + [ 'RestrictionTypes' => [ 'edit', 'move', 'upload' ] ], + ], + 'Existing page with no move' => [ + [ 'edit' ], + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + [ 'RestrictionTypes' => [ 'create', 'edit', 'upload' ] ], + ], + 'Nonexistent file with no upload' => [ + [ 'create' ], + PageIdentityValue::localIdentity( 0, NS_FILE, 'X' ), + [ 'RestrictionTypes' => [ 'create', 'edit', 'move' ] ], + ], + + 'Special page with extra type' => [ + [], + self::newImproperPageIdentity( NS_SPECIAL, 'X' ), + [ 'RestrictionTypes' => $expandedRestrictions ], + ], + 'Media page with extra type' => [ + [], + self::newImproperPageIdentity( NS_MEDIA, 'X' ), + [ 'RestrictionTypes' => $expandedRestrictions ], + ], + 'Nonexistent page with extra type' => [ + [ 'create' ], + PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ), + [ 'RestrictionTypes' => $expandedRestrictions ], + ], + 'Existing page with extra type' => [ + [ 'edit', 'move', 'liquify' ], + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + [ 'RestrictionTypes' => $expandedRestrictions ], + ], + 'Nonexistent file with extra type' => [ + [ 'create', 'upload' ], + PageIdentityValue::localIdentity( 0, NS_FILE, 'X' ), + [ 'RestrictionTypes' => $expandedRestrictions ], + ], + 'Existing file with extra type' => [ + [ 'edit', 'move', 'upload', 'liquify' ], + PageIdentityValue::localIdentity( 1, NS_FILE, 'X' ), + [ 'RestrictionTypes' => $expandedRestrictions ], + ], + + 'Hook' => [ + [ 'move', 'liquify' ], + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + [ 'hookFn' => function ( Title $title, array &$types ): bool { + self::assertEquals( Title::castFromPageIdentity( + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ) ), $title ); + self::assertSame( [ 'edit', 'move' ], $types ); + + $types = [ 'move', 'liquify' ]; + + return false; + } ], + ], + 'Hook not run for special page' => [ + [], + self::newImproperPageIdentity( NS_SPECIAL, 'X' ), + [ 'hookFn' => function () { + $this->fail( 'Should be unreached' ); + } ], + ], + 'Hook not run for media page' => [ + [], + self::newImproperPageIdentity( NS_MEDIA, 'X' ), + [ 'hookFn' => function () { + $this->fail( 'Should be unreached' ); + } ], + ], + ]; + } + + /** + * @covers ::listAllRestrictionTypes + * @dataProvider provideListAllRestrictionTypes + */ + public function testListAllRestrictionTypes( + array $expected, array $args, array $options = [] + ) { + $obj = $this->newRestrictionStore( $options ); + $this->assertSame( $expected, $obj->listAllRestrictionTypes( ...$args ) ); + } + + public function provideListAllRestrictionTypes() { + $expandedRestrictions = array_merge( self::DEFAULT_RESTRICTION_TYPES, [ 'solidify' ] ); + return [ + 'Exists' => [ [ 'edit', 'move', 'upload' ], [ true ] ], + 'Default is exists' => [ [ 'edit', 'move', 'upload' ], [] ], + 'Nonexistent' => [ [ 'create', 'upload' ], [ false ] ], + + 'Exists with extra restriction type' => [ + [ 'edit', 'move', 'upload', 'solidify' ], + [ true ], + [ 'RestrictionTypes' => $expandedRestrictions ], + ], + 'Default is exists with extra restriction type' => [ + [ 'edit', 'move', 'upload', 'solidify' ], + [], + [ 'RestrictionTypes' => $expandedRestrictions ], + ], + 'Nonexistent with extra restriction type' => [ + [ 'create', 'upload' ], + [ false ], + [ 'RestrictionTypes' => $expandedRestrictions ], + ], + + 'Exists with no edit' => [ + [ 'move', 'upload' ], + [ true ], + [ 'RestrictionTypes' => [ 'create', 'move', 'upload' ] ], + ], + 'Exists with only create' => [ + [], + [ true ], + [ 'RestrictionTypes' => [ 'create' ] ], + ], + 'Nonexistent with no create' => [ + [ 'upload' ], + [ false ], + [ 'RestrictionTypes' => [ 'edit', 'move', 'upload', 'solidify' ] ], + ], + 'Nonexistent with no upload' => [ + [ 'create' ], + [ false ], + [ 'RestrictionTypes' => [ 'create', 'edit', 'move', 'solidify' ] ], + ], + 'Nonexistent with no create or upload' => [ + [], + [ false ], + [ 'RestrictionTypes' => [ 'edit', 'move', 'solidify' ] ], + ], + ]; + } + + /** + * @covers ::areRestrictionsLoaded + * @covers ::loadRestrictionsFromRows + * @dataProvider provideAreRestrictionsLoaded + */ + public function testAreRestrictionsLoaded( + bool $expected, PageIdentity $page, ?array $rowsToLoad = null, array $options = [] + ): void { + $obj = $this->newRestrictionStore( $options ); + if ( is_array( $rowsToLoad ) ) { + $obj->loadRestrictionsFromRows( $page, $rowsToLoad ); + } + $this->assertSame( $expected, $obj->areRestrictionsLoaded( $page ) ); + } + + public function provideAreRestrictionsLoaded(): array { + return [ + 'Special page' => [ false, self::newImproperPageIdentity( NS_SPECIAL, 'X' ) ], + 'Media page' => [ false, self::newImproperPageIdentity( NS_MEDIA, 'X' ) ], + 'Regular page' => [ false, PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ) ], + 'Regular page with no restrictions' => + [ true, PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), [] ], + 'Regular page with restrictions' => [ + true, + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ] ], + ], + ]; + } + + /** + * @covers ::areRestrictionsCascading + * @covers ::loadRestrictionsFromRows + * @dataProvider provideAreRestrictionsCascading + */ + public function testAreRestrictionsCascading( + bool $expected, PageIdentity $page, ?array $rowsToLoad, array $options = [] + ): void { + $obj = $this->newRestrictionStore( $options ); + if ( is_array( $rowsToLoad ) ) { + $obj->loadRestrictionsFromRows( $page, $rowsToLoad ); + } + $this->assertSame( $expected, $obj->areRestrictionsCascading( $page ) ); + } + + public function provideAreRestrictionsCascading(): array { + return [ + 'Special page' => [ false, self::newImproperPageIdentity( NS_SPECIAL, 'X' ), null ], + 'Media page' => [ false, self::newImproperPageIdentity( NS_MEDIA, 'X' ), null ], + 'Regular page with no restrictions' => + [ false, PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), [] ], + 'Regular page with restrictions' => [ + false, + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ] ], + ], + 'Regular page with cascading restrictions' => [ + true, + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + [ (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 1 ] ], + ], + 'Regular page with some cascading restrictions and some not' => [ + true, + PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ), + [ + (object)[ 'pr_type' => 'edit', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 0 ], + (object)[ 'pr_type' => 'move', 'pr_level' => 'sysop', + 'pr_expiry' => 'infinity', 'pr_cascade' => 1 ], + ], + ], + ]; + } + + /** + * @covers ::flushRestrictions + */ + public function testFlushRestrictions(): void { + $obj = $this->newRestrictionStore(); + $page = PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ); + $this->assertFalse( $obj->areRestrictionsLoaded( $page ) ); + $obj->loadRestrictionsFromRows( $page, [] ); + $this->assertTrue( $obj->areRestrictionsLoaded( $page ) ); + $obj->flushRestrictions( $page ); + $this->assertFalse( $obj->areRestrictionsLoaded( $page ) ); + } + + /** + * @covers ::getCascadeProtectionSources + * @covers ::getCascadeProtectionSourcesInternal + */ + public function testGetCascadeProtectionSources() { + $obj = $this->newRestrictionStore( [ 'db' => [ DB_REPLICA => [ 'select' => + static function () { + return [ + (object)[ 'pr_page' => 1, 'page_namespace' => NS_MAIN, 'page_title' => 'test', + 'pr_expiry' => 'infinity', 'pr_type' => 'edit', 'pr_level' => 'Sysop' ] + ]; + } + ] ] ] ); + + $page = PageIdentityValue::localIdentity( 1, NS_MAIN, 'X' ); + [ $sources, $restrictions ] = $obj->getCascadeProtectionSources( $page ); + $this->assertCount( 1, $sources ); + $this->assertArrayHasKey( 'edit', $restrictions ); + } + +}