New RestrictionStore service

This allows checking restrictions without a dependency on Title, based
only on a PageIdentity.

Additional fixes along the way:

* Correctly return false instead of 'infinity' for
  getRestrictionExpiry( 'create' ) on an existing page
* Correctly handle non-special pages that can't exist (like media pages)
  in listApplicableRestrictionTypes() (return empty array instead of
  'create')
* Improve readability of isProtected()

The expectation change in TitleTest::testIsProtected() is because the
test was formerly broken, since it set mRestrictions without setting
mRestrictionsLoaded. (Which illustrates how this approach to testing is
essentially broken.)

Co-authored-by: Vedmaka <god.vedmaka@gmail.com>
Bug: T218395
Change-Id: Ia73ea587586cb69eb53265b2f8f7a296a2573dd0
This commit is contained in:
Aryeh Gregor 2021-07-26 16:24:22 +03:00 committed by Daniel Kinzler
parent d55f361a5b
commit cf818256fb
10 changed files with 2261 additions and 469 deletions

View file

@ -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 ===

View file

@ -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

View file

@ -0,0 +1,751 @@
<?php
namespace MediaWiki\Permissions;
use CommentStore;
use DBAccessObjectUtils;
use IDBAccessObject;
use LinkCache;
use MediaWiki\Cache\CacheKeyHelper;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageStore;
use stdClass;
use Title;
use WANObjectCache;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
/**
* Class RestrictionStore
*
* @since 1.37
*/
class RestrictionStore {
/** @internal */
public const CONSTRUCTOR_OPTIONS = [
'NamespaceProtection',
'RestrictionLevels',
'RestrictionTypes',
'SemiprotectedRestrictionLevels',
];
/** @var ServiceOptions */
private $options;
/** @var WANObjectCache */
private $wanCache;
/** @var ILoadBalancer */
private $loadBalancer;
/** @var LinkCache */
private $linkCache;
/** @var CommentStore */
private $commentStore;
/** @var HookContainer */
private $hookContainer;
/** @var HookRunner */
private $hookRunner;
/** @var PageStore */
private $pageStore;
/**
* @var array[] Caching various restrictions data in the following format:
* cache key => [
* 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;
}
}

View file

@ -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(),

View file

@ -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() {

View file

@ -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,

View file

@ -1,13 +1,16 @@
<?php
use MediaWiki\Cache\CacheKeyHelper;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Permissions\RestrictionStore;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\TestingAccessWrapper;
/**
* @group Database
@ -1414,10 +1417,9 @@ class TitleTest extends MediaWikiIntegrationTestCase {
$title = $this->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' );

View file

@ -0,0 +1,177 @@
<?php
namespace MediaWiki\Tests\Integration\Permissions;
use IDBAccessObject;
use MediaWiki\Cache\CacheKeyHelper;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Permissions\RestrictionStore;
use MediaWikiIntegrationTestCase;
use Title;
use Wikimedia\TestingAccessWrapper;
/**
* @group Database
*
* See \MediaWiki\Tests\Unit\Permissions\RestrictionStoreTest
* for unit tests
*
* @coversDefaultClass \MediaWiki\Permissions\RestrictionStore
*/
class RestrictionStoreTest extends MediaWikiIntegrationTestCase {
private const DEFAULT_RESTRICTION_TYPES = [ 'create', 'edit', 'move', 'upload' ];
/** @var WANObjectCache */
private $wanCache;
/** @var ILoadBalancer */
private $loadBalancer;
/** @var LinkCache */
private $linkCache;
/** @var HookContainer */
private $hookContainer;
/** @var CommentStore */
private $commentStore;
/** @var PageStore */
private $pageStore;
private static $testPageRestrictionSource;
private static $testPageRestrictionCascade;
protected function setUp(): void {
parent::setUp();
$services = $this->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]
);
}
}

View file

@ -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 );
}
}

View file

@ -0,0 +1,979 @@
<?php
namespace MediaWiki\Tests\Unit\Permissions;
use DatabaseTestHelper;
use LinkCache;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Page\PageStore;
use MediaWiki\Permissions\RestrictionStore;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWikiUnitTestCase;
use ReflectionClass;
use ReflectionMethod;
use RuntimeException;
use Title;
use UnexpectedValueException;
use WANObjectCache;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
/**
* @coversDefaultClass \MediaWiki\Permissions\RestrictionStore
*/
class RestrictionStoreTest extends MediaWikiUnitTestCase {
use DummyServicesTrait;
private const DEFAULT_RESTRICTION_TYPES = [ 'create', 'edit', 'move', 'upload' ];
/**
* @param array $expectedCalls E.g.:
* [
* DB_REPLICA => [
* '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 ::<public>
* @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 );
}
}