Create UserGroupManager

Introduce a UserGroupManagerFactory and UserGroupManager.
The factory utilizes the same pattern as RevisionStore
for access to user groups of a foreign wiki.

Some user group related methods were ported from User
and UserGroupMembership and deprecated, more methods to
be moved over in future patches, not to make this one to large.

Eventually as all the group-related methods are moved and their
usages are replaced, the need for the UserRightsProxy will disappear,
thus it also will be deprecated and removed. Currently for backwards
compatibility, I've had to create artificial UserIdentityValue
objects in some of the deprecated methods to avoid making transitional
temporary methods in the UserGroupManager that would take user ID
instead of the UserIdentity. All of this will go away once migration
to UserGroupManager is completed.

Bug: T234921
Change-Id: If29c6a03dfdbb80b2e846243f7e384b334da9f07
This commit is contained in:
Petr Pchelko 2019-10-23 20:14:31 -07:00
parent 08dae812d0
commit 40b88d635b
14 changed files with 1529 additions and 579 deletions

View file

@ -1144,6 +1144,14 @@ because of Phabricator reports.
LinkRenderer::normalizeTarget().
* SkinTemplate::getPersonalToolsList() was soft deprecated.
* ChangeTags::truncateTagDescription() has been deprecated.
* The following methods of the User class are deprecated: getGroups,
getGroupMemberships, getEffectiveGroups, getAutomaticGroups,
addGroup, removeGroup, getFormerGroups, getAllGroups, getImplicitGroups.
Use the new UserGroupManager service instead.
* The following methods of the UserGroupMembership class were deprecated:
selectFields, getMembershipsForUser, getMembership, insert, delete,
newFromRow, initFromRow, purgeExpired.
Use the new UserGroupManager service instead.
* …
=== Other changes in 1.35 ===

View file

@ -66,6 +66,8 @@ use MediaWiki\Storage\NameTableStoreFactory;
use MediaWiki\Storage\PageEditStash;
use MediaWiki\User\TalkPageNotificationManager;
use MediaWiki\User\UserEditTracker;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserGroupManagerFactory;
use MediaWiki\User\UserNameUtils;
use MediaWiki\User\UserOptionsLookup;
use MediaWiki\User\UserOptionsManager;
@ -1259,6 +1261,22 @@ class MediaWikiServices extends ServiceContainer {
return $this->getService( 'UserEditTracker' );
}
/**
* @since 1.35
* @return UserGroupManager
*/
public function getUserGroupManager() : UserGroupManager {
return $this->getService( 'UserGroupManager' );
}
/**
* @since 1.35
* @return UserGroupManagerFactory
*/
public function getUserGroupManagerFactory() : UserGroupManagerFactory {
return $this->getService( 'UserGroupManagerFactory' );
}
/**
* @since 1.35
* @return UserNameUtils

View file

@ -1337,7 +1337,7 @@ class PermissionManager {
*
* @since 1.34
*
* @param User|null $user
* @param UserIdentity|null $user
*/
public function invalidateUsersRightsCache( $user = null ) {
if ( $user !== null ) {

View file

@ -101,6 +101,9 @@ use MediaWiki\Storage\SqlBlobStore;
use MediaWiki\User\DefaultOptionsLookup;
use MediaWiki\User\TalkPageNotificationManager;
use MediaWiki\User\UserEditTracker;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserGroupManagerFactory;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserNameUtils;
use MediaWiki\User\UserOptionsLookup;
use MediaWiki\User\UserOptionsManager;
@ -1204,6 +1207,25 @@ return [
);
},
'UserGroupManager' => function ( MediaWikiServices $services ) : UserGroupManager {
return $services->getUserGroupManagerFactory()->getUserGroupManager();
},
'UserGroupManagerFactory' => function ( MediaWikiServices $services ) : UserGroupManagerFactory {
return new UserGroupManagerFactory(
new ServiceOptions(
UserGroupManager::CONSTRUCTOR_OPTIONS, $services->getMainConfig()
),
$services->getConfiguredReadOnlyMode(),
$services->getDBLoadBalancerFactory(),
$services->getHookContainer(),
[ function ( UserIdentity $user ) use ( $services ) {
$services->getPermissionManager()->invalidateUsersRightsCache( $user );
User::newFromIdentity( $user )->invalidateCache();
} ]
);
},
'UserNameUtils' => function ( MediaWikiServices $services ) : UserNameUtils {
$messageFormatterFactory = new MessageFormatterFactory( Message::FORMAT_PLAIN );
return new UserNameUtils(

View file

@ -69,7 +69,7 @@ class User implements IDBAccessObject, UserIdentity {
* Version number to tag cached versions of serialized User objects. Should be increased when
* {@link $mCacheVars} or one of it's members changes.
*/
private const VERSION = 15;
private const VERSION = 16;
/**
* Exclude user options that are set to their default value.
@ -108,8 +108,6 @@ class User implements IDBAccessObject, UserIdentity {
'mEmailTokenExpires',
'mRegistration',
'mEditCount',
// user_groups table
'mGroupMemberships',
// actor table
'mActorId',
];
@ -143,8 +141,6 @@ class User implements IDBAccessObject, UserIdentity {
protected $mRegistration;
/** @var int */
protected $mEditCount;
/** @var UserGroupMembership[] Associative array of (group name => UserGroupMembership object) */
protected $mGroupMemberships;
// @}
// @{
@ -186,12 +182,6 @@ class User implements IDBAccessObject, UserIdentity {
* @var string
*/
protected $mBlockreason;
/** @var array */
protected $mEffectiveGroups;
/** @var array */
protected $mImplicitGroups;
/** @var array */
protected $mFormerGroups;
/** @var AbstractBlock */
protected $mGlobalBlock;
/** @var bool */
@ -471,16 +461,17 @@ class User implements IDBAccessObject, UserIdentity {
* @since 1.25
*/
protected function loadFromCache() {
global $wgFullyInitialised;
$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
$data = $cache->getWithSetCallback(
$this->getCacheKey( $cache ),
$cache::TTL_HOUR,
function ( $oldValue, &$ttl, array &$setOpts ) use ( $cache ) {
function ( $oldValue, &$ttl, array &$setOpts ) use ( $cache, $wgFullyInitialised ) {
$setOpts += Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) );
wfDebug( "User: cache miss for user {$this->mId}" );
$this->loadFromDatabase( self::READ_NORMAL );
$this->loadGroups();
$data = [];
foreach ( self::$mCacheVars as $name ) {
@ -489,13 +480,21 @@ class User implements IDBAccessObject, UserIdentity {
$ttl = $cache->adaptiveTTL( wfTimestamp( TS_UNIX, $this->mTouched ), $ttl );
// if a user group membership is about to expire, the cache needs to
// expire at that time (T163691)
foreach ( $this->mGroupMemberships as $ugm ) {
if ( $ugm->getExpiry() ) {
$secondsUntilExpiry = wfTimestamp( TS_UNIX, $ugm->getExpiry() ) - time();
if ( $secondsUntilExpiry > 0 && $secondsUntilExpiry < $ttl ) {
$ttl = $secondsUntilExpiry;
if ( $wgFullyInitialised ) {
$groupMemberships = MediaWikiServices::getInstance()
->getUserGroupManager()
->getUserGroupMemberships( $this, $this->queryFlagsUsed );
// if a user group membership is about to expire, the cache needs to
// expire at that time (T163691)
foreach ( $groupMemberships as $ugm ) {
if ( $ugm->getExpiry() ) {
$secondsUntilExpiry =
wfTimestamp( TS_UNIX, $ugm->getExpiry() ) - time();
if ( $secondsUntilExpiry > 0 && $secondsUntilExpiry < $ttl ) {
$ttl = $secondsUntilExpiry;
}
}
}
}
@ -1176,7 +1175,6 @@ class User implements IDBAccessObject, UserIdentity {
$this->mEmailToken = '';
$this->mEmailTokenExpires = null;
$this->mRegistration = wfTimestamp( TS_MW );
$this->mGroupMemberships = [];
$this->getHookRunner()->onUserLoadDefaults( $this, $name );
}
@ -1245,7 +1243,7 @@ class User implements IDBAccessObject, UserIdentity {
}
/**
* Load user and user_group data from the database.
* Load user data from the database.
* $this->mId must be set, this is how the user is identified.
*
* @param int $flags User::READ_* constant bitfield
@ -1280,7 +1278,6 @@ class User implements IDBAccessObject, UserIdentity {
if ( $s !== false ) {
// Initialise user table data
$this->loadFromRow( $s );
$this->mGroupMemberships = null; // deferred
$this->getEditCount(); // revalidation for nulls
return true;
}
@ -1311,8 +1308,6 @@ class User implements IDBAccessObject, UserIdentity {
$all = true;
$this->mGroupMemberships = null; // deferred
if ( isset( $row->actor_id ) ) {
$this->mActorId = (int)$row->actor_id;
if ( $this->mActorId !== 0 ) {
@ -1391,19 +1386,11 @@ class User implements IDBAccessObject, UserIdentity {
}
if ( is_array( $data ) ) {
if ( isset( $data['user_groups'] ) && is_array( $data['user_groups'] ) ) {
if ( $data['user_groups'] === [] ) {
$this->mGroupMemberships = [];
} else {
$firstGroup = reset( $data['user_groups'] );
if ( is_array( $firstGroup ) || is_object( $firstGroup ) ) {
$this->mGroupMemberships = [];
foreach ( $data['user_groups'] as $row ) {
$ugm = UserGroupMembership::newFromRow( (object)$row );
$this->mGroupMemberships[$ugm->getGroup()] = $ugm;
}
}
}
MediaWikiServices::getInstance()
->getUserGroupManager()
->loadGroupMembershipsFromArray( $this, $data['user_groups'] );
}
if ( isset( $data['user_properties'] ) && is_array( $data['user_properties'] ) ) {
MediaWikiServices::getInstance()
@ -1425,19 +1412,6 @@ class User implements IDBAccessObject, UserIdentity {
}
}
/**
* Load the groups from the database if they aren't already loaded.
*/
private function loadGroups() {
if ( $this->mGroupMemberships === null ) {
$db = ( $this->queryFlagsUsed & self::READ_LATEST )
? wfGetDB( DB_MASTER )
: wfGetDB( DB_REPLICA );
$this->mGroupMemberships = UserGroupMembership::getMembershipsForUser(
$this->mId, $db );
}
}
/**
* Add the user to the group if he/she meets given criteria.
*
@ -1566,17 +1540,14 @@ class User implements IDBAccessObject, UserIdentity {
$this->mDatePreference = null;
$this->mBlockedby = -1; # Unset
$this->mHash = false;
$this->mEffectiveGroups = null;
$this->mImplicitGroups = null;
$this->mGroupMemberships = null;
$this->mEditCount = null;
// Replacement of former `$this->mRights = null` line
if ( $wgFullyInitialised && $this->mFrom ) {
$services = MediaWikiServices::getInstance();
$services->getPermissionManager()->invalidateUsersRightsCache( $this );
$services->getUserOptionsManager()->clearUserOptionsCache( $this );
$services->getTalkPageNotificationManager()->clearInstanceCache( $this );
$services->getUserGroupManager()->clearCache( $this );
$services->getUserEditTracker()->clearUserEditCache( $this );
}
@ -3011,73 +2982,61 @@ class User implements IDBAccessObject, UserIdentity {
* Get the list of explicit group memberships this user has.
* The implicit * and user groups are not included.
*
* @deprecated since 1.35 Use UserGroupManager::getUserGroups instead.
*
* @return string[] Array of internal group names (sorted since 1.33)
*/
public function getGroups() {
$this->load();
$this->loadGroups();
return array_keys( $this->mGroupMemberships );
return MediaWikiServices::getInstance()
->getUserGroupManager()
->getUserGroups( $this, $this->queryFlagsUsed );
}
/**
* Get the list of explicit group memberships this user has, stored as
* UserGroupMembership objects. Implicit groups are not included.
*
* @deprecated since 1.35 Use UserGroupManager::getUserGroupMemberships instead
*
* @return UserGroupMembership[] Associative array of (group name => UserGroupMembership object)
* @since 1.29
*/
public function getGroupMemberships() {
$this->load();
$this->loadGroups();
return $this->mGroupMemberships;
return MediaWikiServices::getInstance()
->getUserGroupManager()
->getUserGroupMemberships( $this, $this->queryFlagsUsed );
}
/**
* Get the list of implicit group memberships this user has.
* This includes all explicit groups, plus 'user' if logged in,
* '*' for all accounts, and autopromoted groups
*
* @deprecated since 1.35 Use UserGroupManager::getUserEffectiveGroups instead
*
* @param bool $recache Whether to avoid the cache
* @return array Array of String internal group names
*/
public function getEffectiveGroups( $recache = false ) {
if ( $recache || $this->mEffectiveGroups === null ) {
$this->mEffectiveGroups = array_unique( array_merge(
$this->getGroups(), // explicit groups
$this->getAutomaticGroups( $recache ) // implicit groups
) );
// Hook for additional groups
$this->getHookRunner()->onUserEffectiveGroups( $this, $this->mEffectiveGroups );
// Force reindexation of groups when a hook has unset one of them
$this->mEffectiveGroups = array_values( array_unique( $this->mEffectiveGroups ) );
}
return $this->mEffectiveGroups;
return MediaWikiServices::getInstance()
->getUserGroupManager()
->getUserEffectiveGroups( $this, $this->queryFlagsUsed, $recache );
}
/**
* Get the list of implicit group memberships this user has.
* This includes 'user' if logged in, '*' for all accounts,
* and autopromoted groups
*
* @deprecated since 1.35 Use UserGroupManager::getUserImplicitGroups instead.
*
* @param bool $recache Whether to avoid the cache
* @return array Array of String internal group names
*/
public function getAutomaticGroups( $recache = false ) {
if ( $recache || $this->mImplicitGroups === null ) {
$this->mImplicitGroups = [ '*' ];
if ( $this->getId() ) {
$this->mImplicitGroups[] = 'user';
$this->mImplicitGroups = array_unique( array_merge(
$this->mImplicitGroups,
Autopromote::getAutopromoteGroups( $this )
) );
}
if ( $recache ) {
// Assure data consistency with rights/groups,
// as getEffectiveGroups() depends on this function
$this->mEffectiveGroups = null;
}
}
return $this->mImplicitGroups;
return MediaWikiServices::getInstance()
->getUserGroupManager()
->getUserImplicitGroups( $this, $recache );
}
/**
@ -3087,26 +3046,14 @@ class User implements IDBAccessObject, UserIdentity {
*
* The function will not return groups the user had belonged to before MW 1.17
*
* @deprecated since 1.35 Use UserGroupManager::getUserFormerGroups instead.
*
* @return array Names of the groups the user has belonged to.
*/
public function getFormerGroups() {
$this->load();
if ( $this->mFormerGroups === null ) {
$db = ( $this->queryFlagsUsed & self::READ_LATEST )
? wfGetDB( DB_MASTER )
: wfGetDB( DB_REPLICA );
$res = $db->select( 'user_former_groups',
[ 'ufg_group' ],
[ 'ufg_user' => $this->mId ],
__METHOD__ );
$this->mFormerGroups = [];
foreach ( $res as $row ) {
$this->mFormerGroups[] = $row->ufg_group;
}
}
return $this->mFormerGroups;
return MediaWikiServices::getInstance()
->getUserGroupManager()
->getUserFormerGroups( $this, $this->queryFlagsUsed );
}
/**
@ -3132,69 +3079,32 @@ class User implements IDBAccessObject, UserIdentity {
* expiry time. (If $expiry is omitted or null, the membership will be altered to
* never expire.)
*
* @deprecated since 1.35 Use UserGroupManager::addUserToGroup instead
*
* @param string $group Name of the group to add
* @param string|null $expiry Optional expiry timestamp in any format acceptable to
* wfTimestamp(), or null if the group assignment should not expire
* @return bool
*/
public function addGroup( $group, $expiry = null ) {
$this->load();
$this->loadGroups();
if ( $expiry ) {
$expiry = wfTimestamp( TS_MW, $expiry );
}
if ( !$this->getHookRunner()->onUserAddGroup( $this, $group, $expiry ) ) {
return false;
}
// create the new UserGroupMembership and put it in the DB
$ugm = new UserGroupMembership( $this->mId, $group, $expiry );
if ( !$ugm->insert( true ) ) {
return false;
}
$this->mGroupMemberships[$group] = $ugm;
// Refresh the groups caches, and clear the rights cache so it will be
// refreshed on the next call to $this->getRights().
$this->getEffectiveGroups( true );
MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache( $this );
$this->invalidateCache();
return true;
return MediaWikiServices::getInstance()
->getUserGroupManager()
->addUserToGroup( $this, $group, $expiry );
}
/**
* Remove the user from the given group.
* This takes immediate effect.
*
* @deprecated since 1.35 Use UserGroupManager::removeUserFromGroup instead.
*
* @param string $group Name of the group to remove
* @return bool
*/
public function removeGroup( $group ) {
$this->load();
if ( !$this->getHookRunner()->onUserRemoveGroup( $this, $group ) ) {
return false;
}
$ugm = UserGroupMembership::getMembership( $this->mId, $group );
// delete the membership entry
if ( !$ugm || !$ugm->delete() ) {
return false;
}
$this->loadGroups();
unset( $this->mGroupMemberships[$group] );
// Refresh the groups caches, and clear the rights cache so it will be
// refreshed on the next call to $this->getRights().
$this->getEffectiveGroups( true );
MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache( $this );
$this->invalidateCache();
return true;
return MediaWikiServices::getInstance()
->getUserGroupManager()
->removeUserFromGroup( $this, $group );
}
/**
@ -4502,14 +4412,13 @@ class User implements IDBAccessObject, UserIdentity {
* Return the set of defined explicit groups.
* The implicit groups (by default *, 'user' and 'autoconfirmed')
* are not included, as they are defined automatically, not in the database.
* @deprecated since 1.35, use UserGroupManager::listAllGroups instead
* @return array Array of internal group names
*/
public static function getAllGroups() {
global $wgGroupPermissions, $wgRevokePermissions;
return array_values( array_diff(
array_merge( array_keys( $wgGroupPermissions ), array_keys( $wgRevokePermissions ) ),
self::getImplicitGroups()
) );
return MediaWikiServices::getInstance()
->getUserGroupManager()
->listAllGroups();
}
/**
@ -4526,13 +4435,13 @@ class User implements IDBAccessObject, UserIdentity {
/**
* Get a list of implicit groups
* TODO: Should we deprecate this? It's trivial, but we don't want to encourage use of globals.
*
* @deprecated since 1.35, use UserGroupManager::listAllImplicitGroups() instead
* @return array Array of Strings Array of internal group names
*/
public static function getImplicitGroups() {
global $wgImplicitGroups;
return $wgImplicitGroups;
return MediaWikiServices::getInstance()
->getUserGroupManager()
->listAllImplicitGroups();
}
/**
@ -4809,8 +4718,8 @@ class User implements IDBAccessObject, UserIdentity {
$groups = [];
foreach ( MediaWikiServices::getInstance()
->getPermissionManager()
->getGroupsWithPermission( $permission ) as $group ) {
->getPermissionManager()
->getGroupsWithPermission( $permission ) as $group ) {
$groups[] = UserGroupMembership::getLink( $group, RequestContext::getMain(), 'wiki' );
}

View file

@ -0,0 +1,642 @@
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\User;
use Autopromote;
use ConfiguredReadOnlyMode;
use DBAccessObjectUtils;
use DeferredUpdates;
use IDBAccessObject;
use InvalidArgumentException;
use JobQueueGroup;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use ReadOnlyMode;
use User;
use UserGroupExpiryJob;
use UserGroupMembership;
use Wikimedia\Rdbms\DBConnRef;
use Wikimedia\Rdbms\ILBFactory;
use Wikimedia\Rdbms\ILoadBalancer;
/**
* Managers user groups.
* @since 1.35
*/
class UserGroupManager implements IDBAccessObject {
public const CONSTRUCTOR_OPTIONS = [
'ImplicitGroups',
'GroupPermissions',
'RevokePermissions',
];
/** @var ServiceOptions */
private $options;
/** @var ILBFactory */
private $loadBalancerFactory;
/** @var ILoadBalancer */
private $loadBalancer;
/** @var HookContainer */
private $hookContainer;
/** @var HookRunner */
private $hookRunner;
/** @var ReadOnlyMode */
private $readOnlyMode;
/** @var callable[] */
private $clearCacheCallbacks;
/** @var string|false */
private $dbDomain;
/**
* @var array Service caches, an assoc. array keyed after the user-keys generated
* by the getCacheKey method and storing values in the following format:
*
* userKey => [
* 'implicit' => implicit groups cache
* 'effective' => effective groups cache
* 'membership' => [ ] // Array of UserGroupMembership objects
* 'former' => former groups cache
* ]
*/
private $userGroupCache = [];
/**
* @param ServiceOptions $options
* @param ConfiguredReadOnlyMode $configuredReadOnlyMode
* @param ILBFactory $loadBalancerFactory
* @param HookContainer $hookContainer
* @param callable[] $clearCacheCallbacks
* @param string|bool $dbDomain
*/
public function __construct(
ServiceOptions $options,
ConfiguredReadOnlyMode $configuredReadOnlyMode,
ILBFactory $loadBalancerFactory,
HookContainer $hookContainer,
array $clearCacheCallbacks = [],
$dbDomain = false
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $options;
$this->loadBalancerFactory = $loadBalancerFactory;
$this->loadBalancer = $loadBalancerFactory->getMainLB( $dbDomain );
$this->hookContainer = $hookContainer;
$this->hookRunner = new HookRunner( $hookContainer );
// Can't just inject ROM since we LB can be for foreign wiki
$this->readOnlyMode = new ReadOnlyMode( $configuredReadOnlyMode, $this->loadBalancer );
$this->clearCacheCallbacks = $clearCacheCallbacks;
$this->dbDomain = $dbDomain;
}
/**
* Return the set of defined explicit groups.
* The implicit groups (by default *, 'user' and 'autoconfirmed')
* are not included, as they are defined automatically, not in the database.
* @return string[] Array of internal group names
*/
public function listAllGroups() : array {
return array_values( array_diff(
array_merge(
array_keys( $this->options->get( 'GroupPermissions' ) ),
array_keys( $this->options->get( 'RevokePermissions' ) )
),
$this->listAllImplicitGroups()
) );
}
/**
* Get a list of all configured implicit groups
* @return string[]
*/
public function listAllImplicitGroups() : array {
return $this->options->get( 'ImplicitGroups' );
}
/**
* Creates a new UserGroupMembership instance from $row.
* The fields required to build an instance could be
* found using getQueryInfo() method.
*
* @param \stdClass $row A database result object
*
* @return UserGroupMembership
*/
public function newGroupMembershipFromRow( \stdClass $row ) : UserGroupMembership {
return new UserGroupMembership(
(int)$row->ug_user,
$row->ug_group,
$row->ug_expiry === null ? null : wfTimestamp(
TS_MW,
$row->ug_expiry
)
);
}
/**
* Load the user groups cache from the provided user groups data
* @internal for use by the User object only
* @param UserIdentity $user
* @param array $userGroups an array of database query results
*/
public function loadGroupMembershipsFromArray(
UserIdentity $user,
array $userGroups
) {
$userKey = $this->getCacheKey( $user );
$this->userGroupCache[$userKey]['membership'] = [];
reset( $userGroups );
foreach ( $userGroups as $row ) {
$ugm = $this->newGroupMembershipFromRow( $row );
$this->userGroupCache[ $userKey ]['membership'][ $ugm->getGroup() ] = $ugm;
}
}
/**
* Get the list of implicit group memberships this user has.
*
* This includes 'user' if logged in, '*' for all accounts,
* and autopromoted groups
*
* @param UserIdentity $user
* @param bool $recache Whether to avoid the cache
* @return string[] internal group names
*/
public function getUserImplicitGroups( UserIdentity $user, bool $recache = false ) : array {
$userKey = $this->getCacheKey( $user );
if ( $recache || !isset( $this->userGroupCache[$userKey]['implicit'] ) ) {
$groups = [ '*' ];
if ( $user->isRegistered() ) {
$groups[] = 'user';
$groups = array_unique( array_merge(
$groups,
// XXX: the User is necessary to pass it to the `GetAutoPromoteGroups` hook
// within the `getAutopromoteGroups` method
Autopromote::getAutopromoteGroups( User::newFromIdentity( $user ) )
) );
}
$this->userGroupCache[$userKey]['implicit'] = $groups;
if ( $recache ) {
// Assure data consistency with rights/groups,
// as getEffectiveGroups() depends on this function
unset( $this->userGroupCache[$userKey]['effective'] );
}
}
return $this->userGroupCache[$userKey]['implicit'];
}
/**
* Get the list of implicit group memberships the user has.
*
* This includes all explicit groups, plus 'user' if logged in,
* '*' for all accounts, and autopromoted groups
*
* @param UserIdentity $user
* @param int $queryFlags
* @param bool $recache Whether to avoid the cache
* @return string[] Array of String internal group names
*/
public function getUserEffectiveGroups(
UserIdentity $user,
int $queryFlags = self::READ_NORMAL,
bool $recache = false
) : array {
$userKey = $this->getCacheKey( $user );
// Ignore cache is the $recache flag is set, query flags = READ_NORMAL
// or the cache value is missing
if ( $recache
|| $queryFlags !== self::READ_NORMAL
|| !isset( $this->userGroupCache[$userKey]['effective'] )
) {
$groups = array_unique( array_merge(
$this->getUserGroups( $user, $queryFlags ), // explicit groups
$this->getUserImplicitGroups( $user, $recache ) // implicit groups
) );
// TODO: Deprecate passing out user object in the hook by introducing
// an alternative hook
if ( $this->hookContainer->isRegistered( 'UserEffectiveGroups' ) ) {
$userObj = User::newFromIdentity( $user );
$userObj->load();
// Hook for additional groups
$this->hookRunner->onUserEffectiveGroups( $userObj, $groups );
}
// Force reindexation of groups when a hook has unset one of them
$this->userGroupCache[$userKey]['effective'] = array_values( array_unique( $groups ) );
}
return $this->userGroupCache[$userKey]['effective'];
}
/**
* Returns the groups the user has belonged to.
*
* The user may still belong to the returned groups. Compare with getGroups().
*
* The function will not return groups the user had belonged to before MW 1.17
*
* @param UserIdentity $user
* @param int $queryFlags
* @return array Names of the groups the user has belonged to.
*/
public function getUserFormerGroups(
UserIdentity $user,
int $queryFlags = self::READ_NORMAL
) : array {
$userKey = $this->getCacheKey( $user );
if ( isset( $this->userGroupCache[$userKey]['former'] ) ) {
return $this->userGroupCache[$userKey]['former'];
}
$db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
$res = $db->select(
'user_former_groups',
[ 'ufg_group' ],
[ 'ufg_user' => $user->getId() ],
__METHOD__
);
$this->userGroupCache[$userKey]['former'] = [];
foreach ( $res as $row ) {
$this->userGroupCache[$userKey]['former'][] = $row->ufg_group;
}
return $this->userGroupCache[$userKey]['former'];
}
/**
* Get the list of explicit group memberships this user has.
* The implicit * and user groups are not included.
*
* @param UserIdentity $user
* @param int $queryFlags
* @return string[]
*/
public function getUserGroups(
UserIdentity $user,
int $queryFlags = self::READ_NORMAL
) : array {
return array_keys( $this->getUserGroupMemberships( $user, $queryFlags ) );
}
/**
* Loads and returns UserGroupMembership objects for all the groups a user currently
* belongs to.
*
* @param UserIdentity $user the user to search for
* @param int $queryFlags
* @return UserGroupMembership[] Associative array of (group name => UserGroupMembership object)
*/
public function getUserGroupMemberships(
UserIdentity $user,
int $queryFlags = self::READ_NORMAL
) : array {
$userKey = $this->getCacheKey( $user );
// Return cached value (if any) only if the query flags are for READ_NORMAL
// otherwise - ignore cache
if ( $queryFlags === self::READ_NORMAL
&& isset( $this->userGroupCache[$userKey]['membership'] )
) {
/** @suppress PhanTypeMismatchReturn */
return $this->userGroupCache[$userKey]['membership'];
}
$db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
$queryInfo = $this->getQueryInfo();
$res = $db->select(
$queryInfo['tables'],
$queryInfo['fields'],
[ 'ug_user' => $user->getId() ],
__METHOD__,
[],
$queryInfo['joins']
);
$ugms = [];
foreach ( $res as $row ) {
$ugm = $this->newGroupMembershipFromRow( $row );
if ( !$ugm->isExpired() ) {
$ugms[$ugm->getGroup()] = $ugm;
}
}
ksort( $ugms );
$this->userGroupCache[$userKey]['membership'] = $ugms;
return $ugms;
}
/**
* Add the user to the given group. This takes immediate effect.
* If the user is already in the group, the expiry time will be updated to the new
* expiry time. (If $expiry is omitted or null, the membership will be altered to
* never expire.)
*
* @param UserIdentity $user
* @param string $group Name of the group to add
* @param string|null $expiry Optional expiry timestamp in any format acceptable to
* wfTimestamp(), or null if the group assignment should not expire
* @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
*
* @throws InvalidArgumentException
* @return bool
*/
public function addUserToGroup(
UserIdentity $user,
string $group,
string $expiry = null,
bool $allowUpdate = false
) : bool {
if ( $group === null ) {
throw new InvalidArgumentException(
'The group parameter can not be null.'
);
}
if ( $this->readOnlyMode->isReadOnly() ) {
return false;
}
if ( !$user->isRegistered() ) {
throw new InvalidArgumentException(
'UserGroupManager::addUserToGroup() needs a positive user ID. ' .
'Perhaps addGroup() was called before the user was added to the database.'
);
}
if ( $expiry ) {
$expiry = wfTimestamp( TS_MW, $expiry );
}
// TODO: Deprecate passing out user object in the hook by introducing
// an alternative hook
if ( $this->hookContainer->isRegistered( 'UserAddGroup' ) ) {
$userObj = User::newFromIdentity( $user );
$userObj->load();
if ( !$this->hookRunner->onUserAddGroup( $userObj, $group, $expiry ) ) {
return false;
}
}
$dbw = $this->loadBalancer->getConnectionRef( DB_MASTER, [], $this->dbDomain );
$dbw->startAtomic( __METHOD__ );
$dbw->insert(
'user_groups',
[
'ug_user' => $user->getId(),
'ug_group' => $group,
'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null,
],
__METHOD__,
[ 'IGNORE' ]
);
$affected = $dbw->affectedRows();
if ( !$affected ) {
// Conflicting row already exists; it should be overridden if it is either expired
// or if $allowUpdate is true and the current row is different than the loaded row.
$conds = [
'ug_user' => $user->getId(),
'ug_group' => $group
];
if ( $allowUpdate ) {
// Update the current row if its expiry does not match that of the loaded row
$conds[] = $expiry
? "ug_expiry IS NULL OR ug_expiry != {$dbw->addQuotes( $dbw->timestamp( $expiry ) )}"
: 'ug_expiry IS NOT NULL';
} else {
// Update the current row if it is expired
$conds[] = "ug_expiry < {$dbw->addQuotes( $dbw->timestamp() )}";
}
$dbw->update(
'user_groups',
[ 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null ],
$conds,
__METHOD__
);
$affected = $dbw->affectedRows();
}
$dbw->endAtomic( __METHOD__ );
// Purge old, expired memberships from the DB
$fname = __METHOD__;
DeferredUpdates::addCallableUpdate( function () use ( $fname ) {
$dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
$hasExpiredRow = $dbr->selectField(
'user_groups',
'1',
[ "ug_expiry < {$dbr->addQuotes( $dbr->timestamp() )}" ],
$fname
);
if ( $hasExpiredRow ) {
JobQueueGroup::singleton( $this->dbDomain )->push( new UserGroupExpiryJob( [] ) );
}
} );
if ( $affected > 0 ) {
// TODO: optimization: we can avoid re-querying groups if we update caches in place
$this->clearCache( $user );
foreach ( $this->clearCacheCallbacks as $callback ) {
$callback( $user );
}
return true;
}
return false;
}
/**
* Remove the user from the given group. This takes immediate effect.
*
* @param UserIdentity $user
* @param string $group Name of the group to remove
* @return bool
*/
public function removeUserFromGroup( UserIdentity $user, string $group ) : bool {
// TODO: Deprecate passing out user object in the hook by introducing
// an alternative hook
if ( $this->hookContainer->isRegistered( 'UserRemoveGroup' ) ) {
$userObj = User::newFromIdentity( $user );
$userObj->load();
if ( !$this->hookRunner->onUserRemoveGroup( $userObj, $group ) ) {
return false;
}
}
if ( $this->readOnlyMode->isReadOnly() ) {
return false;
}
$dbw = $this->loadBalancer->getConnectionRef( DB_MASTER, [], $this->dbDomain );
$dbw->delete(
'user_groups',
[ 'ug_user' => $user->getId(), 'ug_group' => $group ],
__METHOD__
);
if ( !$dbw->affectedRows() ) {
return false;
}
// Remember that the user was in this group
$dbw->insert(
'user_former_groups',
[ 'ufg_user' => $user->getId(), 'ufg_group' => $group ],
__METHOD__,
[ 'IGNORE' ]
);
// TODO: optimization: we can avoid re-querying groups if we update caches in place
$this->clearCache( $user );
foreach ( $this->clearCacheCallbacks as $callback ) {
$callback( $user );
}
return true;
}
/**
* Return the tables and fields to be selected to construct new UserGroupMembership object
* using newGroupMembershipFromRow method.
*
* @return array With three keys:
* - tables: (string[]) to include in the `$table` to `IDatabase->select()`
* - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
* - joins: (string[]) to include in the `$joins` to `IDatabase->select()`
* @internal
* @phan-return array{tables:string[],fields:string[],joins:string[]}
*/
public function getQueryInfo() : array {
return [
'tables' => [ 'user_groups' ],
'fields' => [
'ug_user',
'ug_group',
'ug_expiry',
],
'joins' => []
];
}
/**
* Purge expired memberships from the user_groups table
* @internal
* @note this could be slow and is intended for use in a background job
* @return int|bool false if purging wasn't attempted (e.g. because of
* readonly), the number of rows purged (might be 0) otherwise
*/
public function purgeExpired() {
if ( $this->readOnlyMode->isReadOnly() ) {
return false;
}
$ticket = $this->loadBalancerFactory->getEmptyTransactionTicket( __METHOD__ );
$dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
$lockKey = "{$dbw->getDomainID()}:UserGroupManager:purge"; // per-wiki
$scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 0 );
if ( !$scopedLock ) {
return false; // already running
}
$now = time();
$purgedRows = 0;
$queryInfo = $this->getQueryInfo();
do {
$dbw->startAtomic( __METHOD__ );
$res = $dbw->select(
$queryInfo['tables'],
$queryInfo['fields'],
[ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp( $now ) ) ],
__METHOD__,
[ 'FOR UPDATE', 'LIMIT' => 100 ],
$queryInfo['joins']
);
if ( $res->numRows() > 0 ) {
$insertData = []; // array of users/groups to insert to user_former_groups
$deleteCond = []; // array for deleting the rows that are to be moved around
foreach ( $res as $row ) {
$insertData[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ];
$deleteCond[] = $dbw->makeList(
[ 'ug_user' => $row->ug_user, 'ug_group' => $row->ug_group ],
$dbw::LIST_AND
);
}
// Delete the rows we're about to move
$dbw->delete(
'user_groups',
$dbw->makeList( $deleteCond, $dbw::LIST_OR ),
__METHOD__
);
// Push the groups to user_former_groups
$dbw->insert(
'user_former_groups',
$insertData,
__METHOD__,
[ 'IGNORE' ]
);
// Count how many rows were purged
$purgedRows += $res->numRows();
}
$dbw->endAtomic( __METHOD__ );
$this->loadBalancerFactory->commitAndWaitForReplication( __METHOD__, $ticket );
} while ( $res->numRows() > 0 );
return $purgedRows;
}
/**
* Cleans cached group memberships for a given user
*
* @param UserIdentity $user
*/
public function clearCache( UserIdentity $user ) {
$userKey = $this->getCacheKey( $user );
unset( $this->userGroupCache[$userKey] );
}
/**
* @param int $queryFlags a bit field composed of READ_XXX flags
* @return DBConnRef
*/
private function getDBConnectionRefForQueryFlags( int $queryFlags ) : DBConnRef {
list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
return $this->loadBalancer->getConnectionRef( $mode, [], $this->dbDomain );
}
/**
* Gets a unique key for various caches.
* @param UserIdentity $user
* @return string
*/
private function getCacheKey( UserIdentity $user ) : string {
return $user->isRegistered() ? "u:{$user->getId()}" : "anon:{$user->getName()}";
}
}

View file

@ -0,0 +1,86 @@
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\User;
use ConfiguredReadOnlyMode;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use Wikimedia\Rdbms\ILBFactory;
/**
* Factory service for UserGroupManager instances. This allows UserGroupManager to be created for
* cross-wiki access.
*
* @since 1.35
*/
class UserGroupManagerFactory {
/** @var ServiceOptions */
private $options;
/** @var ConfiguredReadOnlyMode */
private $configuredReadOnlyMode;
/** @var ILBFactory */
private $dbLoadBalancerFactory;
/** @var callable[] */
private $clearCacheCallbacks;
/** @var HookContainer */
private $hookContainer;
/**
* @param ServiceOptions $options
* @param ConfiguredReadOnlyMode $configuredReadOnlyMode
* @param ILBFactory $dbLoadBalancerFactory
* @param HookContainer $hookContainer
* @param callable[] $clearCacheCallbacks
*/
public function __construct(
ServiceOptions $options,
ConfiguredReadOnlyMode $configuredReadOnlyMode,
ILBFactory $dbLoadBalancerFactory,
HookContainer $hookContainer,
array $clearCacheCallbacks = []
) {
$this->options = $options;
$this->configuredReadOnlyMode = $configuredReadOnlyMode;
$this->dbLoadBalancerFactory = $dbLoadBalancerFactory;
$this->hookContainer = $hookContainer;
$this->clearCacheCallbacks = $clearCacheCallbacks;
}
/**
* @param string|bool $dbDomain
* @return UserGroupManager
*/
public function getUserGroupManager( $dbDomain = false ) : UserGroupManager {
// TODO: Once UserRightsProxy is removed, cache the instance per domain.
return new UserGroupManager(
$this->options,
$this->configuredReadOnlyMode,
$this->dbLoadBalancerFactory,
$this->hookContainer,
$this->clearCacheCallbacks,
$dbDomain
);
}
}

View file

@ -21,6 +21,8 @@
*/
use MediaWiki\MediaWikiServices;
use Wikimedia\Assert\Assert;
use Wikimedia\Assert\ParameterTypeException;
use Wikimedia\Rdbms\IDatabase;
/**
@ -28,13 +30,12 @@ use Wikimedia\Rdbms\IDatabase;
* to a group. For example, the fact that user Mary belongs to the sysop group is a
* user group membership.
*
* The class encapsulates rows in the user_groups table. The logic is low-level and
* doesn't run any hooks. Often, you will want to call User::addGroup() or
* User::removeGroup() instead.
* The class is a pure value object. Use UserGroupManager to modify user group memberships.
*
* @since 1.29
*/
class UserGroupMembership {
/** @var int The ID of the user who belongs to the group */
private $userId;
@ -44,15 +45,35 @@ class UserGroupMembership {
/** @var string|null Timestamp of expiry in TS_MW format, or null if no expiry */
private $expiry;
/** @var bool Expiration flag */
private $expired;
/**
* @param int $userId The ID of the user who belongs to the group
* @param string|null $group The internal group name
* @param string|null $expiry Timestamp of expiry in TS_MW format, or null if no expiry
*/
public function __construct( $userId = 0, $group = null, $expiry = null ) {
self::assertValidSpec( $userId, $group, $expiry );
$this->userId = (int)$userId;
$this->group = $group; // TODO throw on invalid group?
$this->group = $group;
$this->expiry = $expiry ?: null;
$this->expired = $expiry ? wfTimestampNow() > $expiry : false;
}
/**
* Asserts that the given parameters could be used to construct a UserGroupMembership object
*
* @param int $userId
* @param string|null $group
* @param string|null $expiry
*
* @throws ParameterTypeException
*/
private static function assertValidSpec( $userId, $group, $expiry ) {
Assert::parameterType( 'integer', $userId, '$userId' );
Assert::parameterType( [ 'string', 'null' ], $group, '$group' );
Assert::parameterType( [ 'string', 'null' ], $expiry, '$expiry' );
}
/**
@ -76,7 +97,12 @@ class UserGroupMembership {
return $this->expiry;
}
/**
* @deprecated since 1.35
* @param $row
*/
protected function initFromRow( $row ) {
wfDeprecated( __METHOD__, '1.35' );
$this->userId = (int)$row->ug_user;
$this->group = $row->ug_group;
$this->expiry = $row->ug_expiry === null ?
@ -89,24 +115,26 @@ class UserGroupMembership {
*
* @param stdClass $row The row from the user_groups table
* @return UserGroupMembership
*
* @deprecated since 1.35, use UserGroupMembership constructor instead
*/
public static function newFromRow( $row ) {
$ugm = new self;
$ugm->initFromRow( $row );
return $ugm;
return new self(
(int)$row->ug_user,
$row->ug_group,
$row->ug_expiry === null ? null : wfTimestamp( TS_MW, $row->ug_expiry )
);
}
/**
* Returns the list of user_groups fields that should be selected to create
* a new user group membership.
* @return array
*
* @deprecated since 1.35, use UserGroupManager::getQueryInfo instead
*/
public static function selectFields() {
return [
'ug_user',
'ug_group',
'ug_expiry',
];
return MediaWikiServices::getInstance()->getUserGroupManager()->getQueryInfo()['fields'];
}
/**
@ -115,32 +143,20 @@ class UserGroupMembership {
* @throws MWException
* @param IDatabase|null $dbw Optional master database connection to use
* @return bool Whether or not anything was deleted
*
* @deprecated since 1.35, use UserGroupManager::removeUserFromGroup instead
*/
public function delete( IDatabase $dbw = null ) {
if ( wfReadOnly() ) {
return false;
}
if ( $dbw === null ) {
$dbw = wfGetDB( DB_MASTER );
}
$dbw->delete(
'user_groups',
[ 'ug_user' => $this->userId, 'ug_group' => $this->group ],
__METHOD__ );
if ( !$dbw->affectedRows() ) {
return false;
}
// Remember that the user was in this group
$dbw->insert(
'user_former_groups',
[ 'ufg_user' => $this->userId, 'ufg_group' => $this->group ],
__METHOD__,
[ 'IGNORE' ] );
return true;
return MediaWikiServices::getInstance()
->getUserGroupManager()
->removeUserFromGroup(
// TODO: we're forced to forge a User instance here because we don't have
// a username around to create an artificial UserIdentityValue
// and the username is being used down the tree. This will be gone once the
// deprecated method is removed
User::newFromId( $this->getUserId() ),
$this->group
);
}
/**
@ -152,88 +168,31 @@ class UserGroupMembership {
* @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
* @param IDatabase|null $dbw If you have one available
* @return bool Whether or not anything was inserted
*
* @deprecated since 1.35, use UserGroupManager::addUserToGroup instead
*/
public function insert( $allowUpdate = false, IDatabase $dbw = null ) {
if ( $this->group === null ) {
throw new UnexpectedValueException(
'Cannot insert an uninitialized UserGroupMembership instance'
return MediaWikiServices::getInstance()
->getUserGroupManager()
->addUserToGroup(
// TODO: we're forced to forge a User instance here because we don't have
// a username around to create an artificial UserIdentityValue
// and the username is being used down the tree. This will be gone once the
// deprecated method is removed
User::newFromId( $this->getUserId() ),
$this->group,
$this->expiry,
$allowUpdate
);
} elseif ( $this->userId <= 0 ) {
throw new UnexpectedValueException(
'UserGroupMembership::insert() needs a positive user ID. ' .
'Perhaps addGroup() was called before the user was added to the database.'
);
}
$dbw = $dbw ?: wfGetDB( DB_MASTER );
$row = $this->getDatabaseArray( $dbw );
$dbw->startAtomic( __METHOD__ );
$dbw->insert( 'user_groups', $row, __METHOD__, [ 'IGNORE' ] );
$affected = $dbw->affectedRows();
if ( !$affected ) {
// Conflicting row already exists; it should be overriden if it is either expired
// or if $allowUpdate is true and the current row is different than the loaded row.
$conds = [ 'ug_user' => $row['ug_user'], 'ug_group' => $row['ug_group'] ];
if ( $allowUpdate ) {
// Update the current row if its expiry does not match that of the loaded row
$conds[] = $this->expiry
? 'ug_expiry IS NULL OR ug_expiry != ' .
$dbw->addQuotes( $dbw->timestamp( $this->expiry ) )
: 'ug_expiry IS NOT NULL';
} else {
// Update the current row if it is expired
$conds[] = 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() );
}
$dbw->update(
'user_groups',
[ 'ug_expiry' => $this->expiry ? $dbw->timestamp( $this->expiry ) : null ],
$conds,
__METHOD__
);
$affected = $dbw->affectedRows();
}
$dbw->endAtomic( __METHOD__ );
// Purge old, expired memberships from the DB
$fname = __METHOD__;
DeferredUpdates::addCallableUpdate( function () use ( $dbw, $fname ) {
$hasExpiredRow = $dbw->selectField(
'user_groups',
'1',
[ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ],
$fname
);
if ( $hasExpiredRow ) {
JobQueueGroup::singleton()->push( new UserGroupExpiryJob( [] ) );
}
} );
return $affected > 0;
}
/**
* Get an array suitable for passing to $dbw->insert() or $dbw->update()
* @param IDatabase $db
* @return array
*/
protected function getDatabaseArray( IDatabase $db ) {
return [
'ug_user' => $this->userId,
'ug_group' => $this->group,
'ug_expiry' => $this->expiry ? $db->timestamp( $this->expiry ) : null,
];
}
/**
* Has the membership expired?
*
* @return bool
*/
public function isExpired() {
if ( !$this->expiry ) {
return false;
}
return wfTimestampNow() > $this->expiry;
return $this->expired;
}
/**
@ -241,63 +200,13 @@ class UserGroupMembership {
*
* @return int|bool false if purging wasn't attempted (e.g. because of
* readonly), the number of rows purged (might be 0) otherwise
*
* @deprecated since 1.35, use UserGroupManager::purgeExpired instead
*/
public static function purgeExpired() {
$services = MediaWikiServices::getInstance();
if ( $services->getReadOnlyMode()->isReadOnly() ) {
return false;
}
$lbFactory = $services->getDBLoadBalancerFactory();
$ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
$dbw = $services->getDBLoadBalancer()->getConnectionRef( DB_MASTER );
$lockKey = "{$dbw->getDomainID()}:UserGroupMembership:purge"; // per-wiki
$scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 0 );
if ( !$scopedLock ) {
return false; // already running
}
$now = time();
$purgedRows = 0;
do {
$dbw->startAtomic( __METHOD__ );
$res = $dbw->select(
'user_groups',
self::selectFields(),
[ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp( $now ) ) ],
__METHOD__,
[ 'FOR UPDATE', 'LIMIT' => 100 ]
);
if ( $res->numRows() > 0 ) {
$insertData = []; // array of users/groups to insert to user_former_groups
$deleteCond = []; // array for deleting the rows that are to be moved around
foreach ( $res as $row ) {
$insertData[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ];
$deleteCond[] = $dbw->makeList(
[ 'ug_user' => $row->ug_user, 'ug_group' => $row->ug_group ],
$dbw::LIST_AND
);
}
// Delete the rows we're about to move
$dbw->delete(
'user_groups',
$dbw->makeList( $deleteCond, $dbw::LIST_OR ),
__METHOD__
);
// Push the groups to user_former_groups
$dbw->insert( 'user_former_groups', $insertData, __METHOD__, [ 'IGNORE' ] );
// Count how many rows were purged
$purgedRows += $res->numRows();
}
$dbw->endAtomic( __METHOD__ );
$lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
} while ( $res->numRows() > 0 );
return $purgedRows;
return MediaWikiServices::getInstance()
->getUserGroupManager()
->purgeExpired();
}
/**
@ -305,29 +214,21 @@ class UserGroupMembership {
* belongs to.
*
* @param int $userId ID of the user to search for
* @param IDatabase|null $db Optional database connection
* @param IDatabase|null $db unused since 1.35
* @return UserGroupMembership[] Associative array of (group name => UserGroupMembership object)
*
* @deprecated since 1.35, use UserGroupManager::getUserGroupMemberships instead
*/
public static function getMembershipsForUser( $userId, IDatabase $db = null ) {
if ( !$db ) {
$db = wfGetDB( DB_REPLICA );
}
$res = $db->select( 'user_groups',
self::selectFields(),
[ 'ug_user' => $userId ],
__METHOD__ );
$ugms = [];
foreach ( $res as $row ) {
$ugm = self::newFromRow( $row );
if ( !$ugm->isExpired() ) {
$ugms[$ugm->group] = $ugm;
}
}
ksort( $ugms );
return $ugms;
return MediaWikiServices::getInstance()
->getUserGroupManager()
->getUserGroupMemberships(
// TODO: we're forced to forge a User instance here because we don't have
// a username around to create an artificial UserIdentityValue
// and the username is being used down the tree. This will be gone once the
// deprecated method is removed
User::newFromId( $userId )
);
}
/**
@ -337,27 +238,22 @@ class UserGroupMembership {
*
* @param int $userId ID of the user to search for
* @param string $group User group name
* @param IDatabase|null $db Optional database connection
* @param IDatabase|null $db unused since 1.35
* @return UserGroupMembership|false
*
* @deprecated since 1.35, use UserGroupManager::getUserGroupMemberships instead
*/
public static function getMembership( $userId, $group, IDatabase $db = null ) {
if ( !$db ) {
$db = wfGetDB( DB_REPLICA );
}
$row = $db->selectRow( 'user_groups',
self::selectFields(),
[ 'ug_user' => $userId, 'ug_group' => $group ],
__METHOD__ );
if ( !$row ) {
return false;
}
$ugm = self::newFromRow( $row );
if ( !$ugm->isExpired() ) {
return $ugm;
}
return false;
$ugms = MediaWikiServices::getInstance()
->getUserGroupManager()
->getUserGroupMemberships(
// TODO: we're forced to forge a User instance here because we don't have
// a username around to create an artificial UserIdentityValue
// and the username is being used down the tree. This will be gone once the
// deprecated method is removed
User::newFromId( $userId )
);
return $ugms[$group] ?? false;
}
/**
@ -373,9 +269,7 @@ class UserGroupMembership {
* group name message ("Administrators"), omit this parameter.
* @return string
*/
public static function getLink( $ugm, IContextSource $context, $format,
$userName = null
) {
public static function getLink( $ugm, IContextSource $context, $format, $userName = null ) {
if ( $format !== 'wiki' && $format !== 'html' ) {
throw new MWException( 'UserGroupMembership::getLink() $format parameter should be ' .
"'wiki' or 'html'" );
@ -475,4 +369,20 @@ class UserGroupMembership {
}
return false;
}
/**
* Compares two pure value objects
*
* @param UserGroupMembership $ugm
* @return bool
*
* @since 1.35
*/
public function equals( UserGroupMembership $ugm ) {
return (
$ugm->getUserId() === $this->userId
&& $ugm->getGroup() === $this->group
);
}
}

View file

@ -20,6 +20,9 @@
* @file
*/
use MediaWiki\MediaWikiServices;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\Rdbms\IDatabase;
/**
@ -37,6 +40,8 @@ class UserRightsProxy {
private $id;
/** @var array */
private $newOptions;
/** @var UserGroupManager */
private $userGroupManager;
/**
* @see newFromId()
@ -52,6 +57,9 @@ class UserRightsProxy {
$this->name = $name;
$this->id = intval( $id );
$this->newOptions = [];
$this->userGroupManager = MediaWikiServices::getInstance()
->getUserGroupManagerFactory()
->getUserGroupManager( $dbDomain );
}
/**
@ -207,7 +215,12 @@ class UserRightsProxy {
* @since 1.29
*/
public function getGroupMemberships() {
return UserGroupMembership::getMembershipsForUser( $this->id, $this->db );
// TODO: We are creating an artificial UserIdentity to pass on to the user group manager.
// After all the relevant UserGroupMemberships methods are ported into UserGroupManager,
// the usages of this class will be changed into usages of the UserGroupManager,
// thus the need of this class and the need of this artificial UserIdentityValue will parish.
$user = new UserIdentityValue( $this->getId(), $this->getName(), 0 );
return $this->userGroupManager->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST );
}
/**
@ -218,12 +231,13 @@ class UserRightsProxy {
* @return bool
*/
public function addGroup( $group, $expiry = null ) {
if ( $expiry ) {
$expiry = wfTimestamp( TS_MW, $expiry );
}
$ugm = new UserGroupMembership( $this->id, $group, $expiry );
return $ugm->insert( true, $this->db );
return $this->userGroupManager->addUserToGroup(
// TODO: Artificial UserIdentity just for passing the id and name.
// see comment in getGroupMemberships.
new UserIdentityValue( $this->getId(), $this->getName(), 0 ),
$group,
$expiry
);
}
/**
@ -233,11 +247,12 @@ class UserRightsProxy {
* @return bool
*/
public function removeGroup( $group ) {
$ugm = UserGroupMembership::getMembership( $this->id, $group, $this->db );
if ( !$ugm ) {
return false;
}
return $ugm->delete( $this->db );
return $this->userGroupManager->removeUserFromGroup(
// TODO: Artificial UserIdentity just for passing the id and name.
// see comment in getGroupMemberships.
new UserIdentityValue( $this->getId(), $this->getName(), 0 ),
$group
);
}
/**

View file

@ -0,0 +1,398 @@
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\Tests\User;
use HashConfig;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MediaWikiServices;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserIdentity;
use MediaWikiIntegrationTestCase;
use User;
/**
* @covers \MediaWiki\User\UserGroupManager
* @group Database
*/
class UserGroupManagerTest extends MediaWikiIntegrationTestCase {
private const GROUP = 'user_group_manager_test_group';
/** @var string */
private $expiryTime;
private function getManager( array $configOverrides = [] ) : UserGroupManager {
$services = MediaWikiServices::getInstance();
return new UserGroupManager(
new ServiceOptions(
UserGroupManager::CONSTRUCTOR_OPTIONS,
new HashConfig(
array_merge( [
'GroupPermissions' => [
self::GROUP => [
'runtest' => true,
]
],
'ImplicitGroups' => [ '*', 'user', 'autoconfirmed' ],
'RevokePermissions' => []
], $configOverrides )
)
),
$services->getConfiguredReadOnlyMode(),
$services->getDBLoadBalancerFactory(),
$services->getHookContainer()
);
}
protected function setUp() : void {
parent::setUp();
$this->tablesUsed[] = 'user';
$this->tablesUsed[] = 'user_groups';
$this->tablesUsed[] = 'user_former_groups';
$this->expiryTime = wfTimestamp( TS_MW, time() + 100500 );
}
/**
* @param UserGroupManager $manager
* @param UserIdentity $user
* @param string $group
* @param string|null $expiry
*/
private function assertMembership(
UserGroupManager $manager,
UserIdentity $user,
string $group,
string $expiry = null
) {
$this->assertContains( $group, $manager->getUserGroups( $user ) );
$memberships = $manager->getUserGroupMemberships( $user );
$this->assertArrayHasKey( $group, $memberships );
$membership = $memberships[$group];
$this->assertSame( $group, $membership->getGroup() );
$this->assertSame( $user->getId(), $membership->getUserId() );
$this->assertSame( $expiry, $membership->getExpiry() );
}
/**
* @covers \MediaWiki\User\UserGroupManager::newGroupMembershipFromRow
*/
public function testNewGroupMembershipFromRow() {
$row = new \stdClass();
$row->ug_user = '1';
$row->ug_group = __METHOD__;
$row->ug_expiry = null;
$membership = $this->getManager()->newGroupMembershipFromRow( $row );
$this->assertSame( 1, $membership->getUserId() );
$this->assertSame( __METHOD__, $membership->getGroup() );
$this->assertNull( $membership->getExpiry() );
}
/**
* @covers \MediaWiki\User\UserGroupManager::newGroupMembershipFromRow
*/
public function testNewGroupMembershipFromRowExpiring() {
$row = new \stdClass();
$row->ug_user = '1';
$row->ug_group = __METHOD__;
$row->ug_expiry = $this->expiryTime;
$membership = $this->getManager()->newGroupMembershipFromRow( $row );
$this->assertSame( 1, $membership->getUserId() );
$this->assertSame( __METHOD__, $membership->getGroup() );
$this->assertSame( $this->expiryTime, $membership->getExpiry() );
}
/**
* @covers \MediaWiki\User\UserGroupManager::getUserImplicitGroups
*/
public function testGetImplicitGroups() {
$manager = $this->getManager();
$user = $this->getTestUser( 'unittesters' )->getUser();
$this->assertArrayEquals(
[ '*', 'user', 'autoconfirmed' ],
$manager->getUserImplicitGroups( $user )
);
$user = $this->getTestUser( [ 'bureaucrat', 'test' ] )->getUser();
$this->assertArrayEquals(
[ '*', 'user', 'autoconfirmed' ],
$manager->getUserImplicitGroups( $user )
);
$this->assertTrue(
$manager->addUserToGroup( $user, self::GROUP ),
'Sanity: added user to group'
);
$this->assertArrayEquals(
[ '*', 'user', 'autoconfirmed' ],
$manager->getUserImplicitGroups( $user )
);
$user = User::newFromName( 'UTUser1' );
$this->assertSame( [ '*' ], $manager->getUserImplicitGroups( $user ) );
$this->setMwGlobals( [
'wgAutopromote' => [
'dummy' => APCOND_EMAILCONFIRMED
]
] );
$user = $this->getTestUser()->getUser();
$user->confirmEmail();
$this->assertArrayEquals(
[ '*', 'user', 'dummy' ],
$manager->getUserImplicitGroups( $user )
);
$user = $this->getTestUser( [ 'dummy' ] )->getUser();
$user->confirmEmail();
$this->assertArrayEquals(
[ '*', 'user', 'dummy' ],
$manager->getUserImplicitGroups( $user )
);
}
public function provideGetEffectiveGroups() {
yield [ [], [ '*', 'user', 'autoconfirmed' ] ];
yield [ [ 'bureaucrat', 'test' ], [ '*', 'user', 'autoconfirmed', 'bureaucrat', 'test' ] ];
yield [ [ 'autoconfirmed', 'test' ], [ '*', 'user', 'autoconfirmed', 'test' ] ];
}
/**
* @dataProvider provideGetEffectiveGroups
* @covers \MediaWiki\User\UserGroupManager::getUserEffectiveGroups
*/
public function testGetEffectiveGroups( $userGroups, $effectiveGroups ) {
$manager = $this->getManager();
$user = $this->getTestUser( $userGroups )->getUser();
$this->assertArrayEquals( $effectiveGroups, $manager->getUserEffectiveGroups( $user ) );
}
/**
* @covers \MediaWiki\User\UserGroupManager::getUserEffectiveGroups
*/
public function testGetEffectiveGroupsHook() {
$manager = $this->getManager();
$user = $this->getTestUser()->getUser();
$this->setTemporaryHook(
'UserEffectiveGroups',
function ( UserIdentity $hookUser, array &$groups ) use ( $user ) {
$this->assertTrue( $hookUser->equals( $user ) );
$groups[] = 'from_hook';
}
);
$this->assertContains( 'from_hook', $manager->getUserEffectiveGroups( $user ) );
}
/**
* @covers \MediaWiki\User\UserGroupManager::addUserToGroup
* @covers \MediaWiki\User\UserGroupManager::getUserGroups
* @covers \MediaWiki\User\UserGroupManager::getUserGroupMemberships
*/
public function testAddUserToGroup() {
$manager = $this->getManager();
$user = $this->getMutableTestUser()->getUser();
$result = $manager->addUserToGroup( $user, self::GROUP );
$this->assertTrue( $result );
$this->assertMembership( $manager, $user, self::GROUP );
$manager->clearCache( $user );
$this->assertMembership( $manager, $user, self::GROUP );
// try updating without allowUpdate. Should fail
$result = $manager->addUserToGroup( $user, self::GROUP, $this->expiryTime );
$this->assertFalse( $result );
// now try updating with allowUpdate
$result = $manager->addUserToGroup( $user, self::GROUP, $this->expiryTime, true );
$this->assertTrue( $result );
$this->assertMembership( $manager, $user, self::GROUP, $this->expiryTime );
$manager->clearCache( $user );
$this->assertMembership( $manager, $user, self::GROUP, $this->expiryTime );
}
/**
* @covers \MediaWiki\User\UserGroupManager::addUserToGroup
*/
public function testAddUserToGroupHookAbort() {
$manager = $this->getManager();
$user = $this->getTestUser()->getUser();
$originalGroups = $manager->getUserGroups( $user );
$this->setTemporaryHook(
'UserAddGroup',
function ( UserIdentity $hookUser ) use ( $user ) {
$this->assertTrue( $hookUser->equals( $user ) );
return false;
}
);
$this->assertFalse( $manager->addUserToGroup( $user, 'test_group' ) );
$this->assertArrayEquals( $originalGroups, $manager->getUserGroups( $user ) );
}
/**
* @covers \MediaWiki\User\UserGroupManager::addUserToGroup
*/
public function testAddUserToGroupHookModify() {
$manager = $this->getManager();
$user = $this->getTestUser()->getUser();
$this->setTemporaryHook(
'UserAddGroup',
function ( UserIdentity $hookUser, &$group, &$hookExp ) use ( $user ) {
$this->assertTrue( $hookUser->equals( $user ) );
$this->assertSame( self::GROUP, $group );
$this->assertSame( $this->expiryTime, $hookExp );
$group = 'from_hook';
$hookExp = null;
return true;
}
);
$this->assertTrue( $manager->addUserToGroup( $user, self::GROUP, $this->expiryTime ) );
$this->assertContains( 'from_hook', $manager->getUserGroups( $user ) );
$this->assertNotContains( self::GROUP, $manager->getUserGroups( $user ) );
$this->assertNull( $manager->getUserGroupMemberships( $user )['from_hook']->getExpiry() );
}
/**
* @covers \MediaWiki\User\UserGroupManager::removeUserFromGroup
* @covers \MediaWiki\User\UserGroupManager::getUserFormerGroups
* @covers \MediaWiki\User\UserGroupManager::getUserGroups
* @covers \MediaWiki\User\UserGroupManager::getUserGroupMemberships
*/
public function testRemoveUserFromGroup() {
$manager = $this->getManager();
$user = $this->getMutableTestUser( [ self::GROUP ] )->getUser();
$this->assertMembership( $manager, $user, self::GROUP );
$result = $manager->removeUserFromGroup( $user, self::GROUP );
$this->assertTrue( $result );
$this->assertNotContains( self::GROUP,
$manager->getUserGroups( $user ) );
$this->assertArrayNotHasKey( self::GROUP,
$manager->getUserGroupMemberships( $user ) );
$this->assertContains( self::GROUP,
$manager->getUserFormerGroups( $user ) );
$manager->clearCache( $user );
$this->assertNotContains( self::GROUP,
$manager->getUserGroups( $user ) );
$this->assertArrayNotHasKey( self::GROUP,
$manager->getUserGroupMemberships( $user ) );
$this->assertContains( self::GROUP,
$manager->getUserFormerGroups( $user ) );
}
/**
* @covers \MediaWiki\User\UserGroupManager::removeUserFromGroup
*/
public function testRemoveUserToGroupHookAbort() {
$manager = $this->getManager();
$user = $this->getTestUser( [ self::GROUP ] )->getUser();
$originalGroups = $manager->getUserGroups( $user );
$this->setTemporaryHook(
'UserRemoveGroup',
function ( UserIdentity $hookUser ) use ( $user ) {
$this->assertTrue( $hookUser->equals( $user ) );
return false;
}
);
$this->assertFalse( $manager->removeUserFromGroup( $user, self::GROUP ) );
$this->assertArrayEquals( $originalGroups, $manager->getUserGroups( $user ) );
}
/**
* @covers \MediaWiki\User\UserGroupManager::removeUserFromGroup
*/
public function testRemoveUserFromGroupHookModify() {
$manager = $this->getManager();
$user = $this->getTestUser( [ self::GROUP, 'from_hook' ] )->getUser();
$this->setTemporaryHook(
'UserRemoveGroup',
function ( UserIdentity $hookUser, &$group ) use ( $user ) {
$this->assertTrue( $hookUser->equals( $user ) );
$this->assertSame( self::GROUP, $group );
$group = 'from_hook';
return true;
}
);
$this->assertTrue( $manager->removeUserFromGroup( $user, self::GROUP ) );
$this->assertNotContains( 'from_hook', $manager->getUserGroups( $user ) );
$this->assertContains( self::GROUP, $manager->getUserGroups( $user ) );
}
/**
* @covers \MediaWiki\User\UserGroupManager::purgeExpired
*/
public function testPurgeExpired() {
$manager = $this->getManager();
$user = $this->getTestUser()->getUser();
$expiryInPast = wfTimestamp( TS_MW, time() - 100500 );
$this->assertTrue(
$manager->addUserToGroup( $user, 'expired', $expiryInPast ),
'Sanity: can add expired group'
);
$manager->purgeExpired();
$this->assertNotContains( 'expired', $manager->getUserGroups( $user ) );
$this->assertArrayNotHasKey( 'expired', $manager->getUserGroupMemberships( $user ) );
$this->assertContains( 'expired', $manager->getUserFormerGroups( $user ) );
}
/**
* @covers \MediaWiki\User\UserGroupManager::listAllGroups
*/
public function testGetAllGroups() {
$manager = $this->getManager( [
'GroupPermissions' => [
__METHOD__ => [ 'test' => true ],
'implicit' => [ 'test' => true ]
],
'RevokePermissions' => [
'revoked' => [ 'test' => true ]
],
'ImplicitGroups' => [ 'implicit' ]
] );
$this->assertArrayEquals( [ __METHOD__, 'revoked' ], $manager->listAllGroups() );
}
/**
* @covers \MediaWiki\User\UserGroupManager::listAllImplicitGroups
*/
public function testGetAllImplicitGroups() {
$manager = $this->getManager( [
'ImplicitGroups' => [ __METHOD__ ]
] );
$this->assertArrayEquals( [ __METHOD__ ], $manager->listAllImplicitGroups() );
}
/**
* @covers \MediaWiki\User\UserGroupManager::loadGroupMembershipsFromArray
*/
public function testLoadGroupMembershipsFromArray() {
$manager = $this->getManager();
$user = $this->getTestUser()->getUser();
$row = new \stdClass();
$row->ug_user = $user->getId();
$row->ug_group = 'test';
$row->ug_expiry = null;
$manager->loadGroupMembershipsFromArray( $user, [ $row ] );
$memberships = $manager->getUserGroupMemberships( $user );
$this->assertCount( 1, $memberships );
$this->assertArrayHasKey( 'test', $memberships );
$this->assertSame( $user->getId(), $memberships['test']->getUserId() );
$this->assertSame( 'test', $memberships['test']->getGroup() );
}
}

View file

@ -1,29 +1,9 @@
<?php
use MediaWiki\MediaWikiServices;
use Wikimedia\Assert\ParameterTypeException;
/**
* @group Database
*/
class UserGroupMembershipTest extends MediaWikiTestCase {
protected $tablesUsed = [ 'user', 'user_groups' ];
/**
* @var User Belongs to no groups
*/
protected $userNoGroups;
/**
* @var User Belongs to the 'unittesters' group indefinitely, and the
* 'testwriters' group with expiry
*/
protected $userTester;
/**
* @var string The timestamp, in TS_MW format, of the expiry of $userTester's
* membership in the 'testwriters' group
*/
protected $expiryTime;
protected function setUp() : void {
parent::setUp();
@ -37,179 +17,100 @@ class UserGroupMembershipTest extends MediaWikiTestCase {
]
]
] );
}
$this->userNoGroups = new User;
$this->userNoGroups->setName( 'NoGroups' );
$this->userNoGroups->addToDatabase();
$this->userTester = new User;
$this->userTester->setName( 'Tester' );
$this->userTester->addToDatabase();
$this->userTester->addGroup( 'unittesters' );
$this->expiryTime = wfTimestamp( TS_MW, time() + 100500 );
$this->userTester->addGroup( 'testwriters', $this->expiryTime );
public function provideInstantiationValidationErrors() {
return [
[ 'A', null, null, 'Bad value for parameter $userId: must be a integer' ],
[ 1, 1, null, 'Bad value for parameter $group: must be a string' ],
[ 1, null, 1, 'Bad value for parameter $expiry: must be a string' ],
];
}
/**
* @covers UserGroupMembership::insert
* @covers UserGroupMembership::delete
* @param $userId
* @param $group
* @param $expiry
* @param $exception
*
* @dataProvider provideInstantiationValidationErrors
* @covers UserGroupMembership
*/
public function testAddAndRemoveGroups() {
$user = $this->getMutableTestUser()->getUser();
// basic tests
$ugm = new UserGroupMembership( $user->getId(), 'unittesters' );
$this->assertTrue( $ugm->insert() );
$user->clearInstanceCache();
$this->assertContains( 'unittesters', $user->getGroups() );
$this->assertArrayHasKey( 'unittesters', $user->getGroupMemberships() );
$this->assertTrue( MediaWikiServices::getInstance()
->getPermissionManager()
->userHasRight( $user, 'runtest' ) );
// try updating without allowUpdate. Should fail
$ugm = new UserGroupMembership( $user->getId(), 'unittesters', $this->expiryTime );
$this->assertFalse( $ugm->insert() );
// now try updating with allowUpdate
$this->assertTrue( $ugm->insert( 2 ) );
$user->clearInstanceCache();
$this->assertContains( 'unittesters', $user->getGroups() );
$this->assertArrayHasKey( 'unittesters', $user->getGroupMemberships() );
$this->assertTrue( MediaWikiServices::getInstance()
->getPermissionManager()
->userHasRight( $user, 'runtest' ) );
// try removing the group
$ugm->delete();
$user->clearInstanceCache();
$this->assertThat( $user->getGroups(),
$this->logicalNot( $this->contains( 'unittesters' ) ) );
$this->assertThat( $user->getGroupMemberships(),
$this->logicalNot( $this->arrayHasKey( 'unittesters' ) ) );
$this->assertFalse( MediaWikiServices::getInstance()
->getPermissionManager()
->userHasRight( $user, 'runtest' ) );
// check that the user group is now in user_former_groups
$this->assertContains( 'unittesters', $user->getFormerGroups() );
public function testInstantiationValidationErrors( $userId, $group, $expiry, $exception ) {
$this->expectExceptionMessage( $exception );
$this->expectException( ParameterTypeException::class );
$ugm = new UserGroupMembership( $userId, $group, $expiry );
}
private function addUserTesterToExpiredGroup() {
// put $userTester in a group with expiry in the past
$ugm = new UserGroupMembership( $this->userTester->getId(), 'sysop', '20010102030405' );
$ugm->insert();
public function provideInstantiationValidation() {
return [
[ 1, null, null, 1, null, null ],
[ 1, 'test', null, 1, 'test', null ],
[ 1, 'test', '12345', 1, 'test', '12345' ]
];
}
/**
* @covers UserGroupMembership::getMembershipsForUser
* @param $userId
* @param $group
* @param $expiry
* @param $userId_
* @param $group_
* @param $expiry_
*
* @dataProvider provideInstantiationValidation
* @covers UserGroupMembership
*/
public function testGetMembershipsForUser() {
$this->addUserTesterToExpiredGroup();
// check that the user in no groups has no group memberships
$ugms = UserGroupMembership::getMembershipsForUser( $this->userNoGroups->getId() );
$this->assertSame( [], $ugms );
// check that the user in 2 groups has 2 group memberships
$testerUserId = $this->userTester->getId();
$ugms = UserGroupMembership::getMembershipsForUser( $testerUserId );
$this->assertCount( 2, $ugms );
// check that the required group memberships are present on $userTester,
// with the correct user IDs and expiries
$expectedGroups = [ 'unittesters', 'testwriters' ];
foreach ( $expectedGroups as $group ) {
$this->assertArrayHasKey( $group, $ugms );
$this->assertEquals( $ugms[$group]->getUserId(), $testerUserId );
$this->assertEquals( $ugms[$group]->getGroup(), $group );
if ( $group === 'unittesters' ) {
$this->assertNull( $ugms[$group]->getExpiry() );
} elseif ( $group === 'testwriters' ) {
$this->assertEquals( $ugms[$group]->getExpiry(), $this->expiryTime );
}
}
public function testInstantiation( $userId, $group, $expiry, $userId_, $group_, $expiry_ ) {
$ugm = new UserGroupMembership( $userId, $group, $expiry );
$this->assertSame(
$userId_,
$ugm->getUserId()
);
$this->assertSame(
$group_,
$ugm->getGroup()
);
$this->assertSame(
$expiry_,
$ugm->getExpiry()
);
}
/**
* @covers UserGroupMembership::getMembership
* @covers UserGroupMembership::equals
*/
public function testGetMembership() {
$this->addUserTesterToExpiredGroup();
// groups that the user doesn't belong to shouldn't be returned
$ugm = UserGroupMembership::getMembership( $this->userNoGroups->getId(), 'sysop' );
$this->assertFalse( $ugm );
// implicit groups shouldn't be returned
$ugm = UserGroupMembership::getMembership( $this->userNoGroups->getId(), 'user' );
$this->assertFalse( $ugm );
// expired groups shouldn't be returned
$ugm = UserGroupMembership::getMembership( $this->userTester->getId(), 'sysop' );
$this->assertFalse( $ugm );
// groups that the user does belong to should be returned with correct properties
$ugm = UserGroupMembership::getMembership( $this->userTester->getId(), 'unittesters' );
$this->assertInstanceOf( UserGroupMembership::class, $ugm );
$this->assertEquals( $ugm->getUserId(), $this->userTester->getId() );
$this->assertEquals( $ugm->getGroup(), 'unittesters' );
$this->assertNull( $ugm->getExpiry() );
public function testComparison() {
$ugm1 = new UserGroupMembership( 1, 'test', '67890' );
$ugm2 = new UserGroupMembership( 1, 'test', '67890' );
$ugm3 = new UserGroupMembership( 1, 'fail', '67890' );
$ugm4 = new UserGroupMembership( 1, 'fail', '12345' );
$this->assertTrue( $ugm1->equals( $ugm2 ) );
$this->assertTrue( $ugm2->equals( $ugm1 ) );
$this->assertFalse( $ugm1->equals( $ugm3 ) );
$this->assertFalse( $ugm2->equals( $ugm3 ) );
$this->assertFalse( $ugm3->equals( $ugm1 ) );
// Ensure expiry is ignored
$this->assertTrue( $ugm3->equals( $ugm4 ) );
}
/**
* @covers UserGroupMembership::getLink
* @covers UserGroupMembership::isExpired
*/
public function testGetLink() {
$this->setMwGlobals( [
'wgMetaNamespace' => 'Project',
'wgScriptPath' => '/w',
'wgScript' => '/w/index.php'
] );
$user = $this->getMutableTestUser()->getUser();
$ugm = new UserGroupMembership( $user->getId(), 'unittesters' );
/** @var IContextSource $context */
$context = $this->getMockBuilder( ContextSource::class )
->disableOriginalConstructor()
->getMock();
$this->assertSame(
'unittesters',
UserGroupMembership::getLink( $ugm, $context, 'wiki' )
public function testIsExpired() {
$ts = wfTimestamp( TS_MW, time() - 100 );
$ugm = new UserGroupMembership( 1, null, $ts );
$this->assertTrue(
$ugm->isExpired()
);
$this->assertSame(
'unittesters',
UserGroupMembership::getLink( $ugm, $context, 'html' )
$ts = wfTimestamp( TS_MW, time() + 100 );
$ugm = new UserGroupMembership( 1, null, $ts );
$this->assertFalse(
$ugm->isExpired()
);
$this->assertSame(
'unittesters',
UserGroupMembership::getLink( $ugm, $context, 'html', $user->getName() )
);
$this->assertSame(
'unittesters',
UserGroupMembership::getLink( $ugm, $context, 'wiki', $user->getName() )
);
$ugm = new UserGroupMembership( $user->getId(), 'sysop' );
$this->assertSame(
'[[Project:Administrators|Administrators]]',
UserGroupMembership::getLink( $ugm, $context, 'wiki' )
);
$this->assertSame(
'<a href="/w/index.php?title=Project:Administrators&amp;action=edit&amp;' .
'redlink=1" class="new" title="Project:Administrators (page does not exist)">' .
'Administrators</a>',
UserGroupMembership::getLink( $ugm, $context, 'html' )
);
$this->assertSame(
'[[Project:Administrators|administrator]]',
UserGroupMembership::getLink( $ugm, $context, 'wiki', $user->getName() )
);
$this->assertSame(
'<a href="/w/index.php?title=Project:Administrators&amp;action=edit&amp;' .
'redlink=1" class="new" title="Project:Administrators (page does not exist)">' .
'administrator</a>',
UserGroupMembership::getLink( $ugm, $context, 'html', $user->getName() )
$ugm = new UserGroupMembership( 1, null, null );
$this->assertFalse(
$ugm->isExpired()
);
}

View file

@ -2256,33 +2256,19 @@ class UserTest extends MediaWikiTestCase {
* @covers User::getGroups
*/
public function testGetGroups() {
$user = $this->user;
$reflectionClass = new ReflectionClass( 'User' );
$reflectionProperty = $reflectionClass->getProperty( 'mLoadedItems' );
$reflectionProperty->setAccessible( true );
$reflectionProperty->setValue( $user, true );
$reflectionProperty = $reflectionClass->getProperty( 'mGroupMemberships' );
$reflectionProperty->setAccessible( true );
$reflectionProperty->setValue( $user, [ 'a' => 1, 'b' => 2 ] );
$this->assertSame( [ 'a', 'b' ], $user->getGroups() );
$user = $this->getTestUser( [ 'a', 'b' ] )->getUser();
$this->assertArrayEquals( [ 'a', 'b' ], $user->getGroups() );
}
/**
* @covers User::getFormerGroups
*/
public function testGetFormerGroups() {
$user = $this->user;
$reflectionClass = new ReflectionClass( 'User' );
$reflectionProperty = $reflectionClass->getProperty( 'mFormerGroups' );
$reflectionProperty->setAccessible( true );
$reflectionProperty->setValue( $user, [ 1, 2, 3 ] );
$this->assertSame( [ 1, 2, 3 ], $user->getFormerGroups() );
$reflectionProperty->setValue( $user, null );
$this->assertSame( [], $user->getFormerGroups() );
$user = $this->getTestUser( [ 'a', 'b', 'c' ] )->getUser();
$this->assertArrayEquals( [], $user->getFormerGroups() );
$user->addGroup( 'test' );
$user->removeGroup( 'test' );
$reflectionProperty->setValue( $user, null );
$this->assertSame( [ 'test' ], $user->getFormerGroups() );
$this->assertArrayEquals( [ 'test' ], $user->getFormerGroups() );
}
/**
@ -2292,15 +2278,12 @@ class UserTest extends MediaWikiTestCase {
$user = $this->getTestUser()->getUser();
$this->assertSame( [], $user->getGroups() );
$this->assertTrue( $user->addGroup( 'test', '20010115123456' ) );
$this->assertSame( [ 'test' ], $user->getGroups() );
$this->assertTrue( $user->addGroup( 'test' ) );
$this->assertArrayEquals( [ 'test' ], $user->getGroups() );
$this->assertTrue( $user->addGroup( 'test2' ) );
$this->assertArrayEquals( [ 'test', 'test2' ], $user->getGroups() );
$this->assertFalse( $user->addGroup( 'test2' ) );
$this->assertArrayEquals( [ 'test', 'test2' ], $user->getGroups() );
$this->setTemporaryHook( 'UserAddGroup', function ( $user, &$group, &$expiry ) {
return false;
} );

View file

@ -130,6 +130,16 @@ trait FactoryArgTestTrait {
$this->assertFalse( true, "Param $name not received by " . static::getInstanceClass() );
}
/**
* Override to return a list of constructor parameters that are not stored
* in the instance properties directly, so should not be verified with
* assertInstanceReceivedParam.
* @return string[]
*/
protected function getIgnoredParamNames() {
return [ 'hookContainer' ];
}
public function testAllArgumentsWerePassed() {
$factoryClass = static::getFactoryClass();
@ -143,7 +153,7 @@ trait FactoryArgTestTrait {
$this->createInstanceFromFactory( new $factoryClass( ...array_values( $mocks ) ) );
foreach ( $mocks as $name => $mock ) {
if ( $name === 'hookContainer' ) {
if ( in_array( $name, $this->getIgnoredParamNames() ) ) {
continue;
}
$this->assertInstanceReceivedParam( $instance, $name, $mock );

View file

@ -0,0 +1,48 @@
<?php
namespace MediaWiki\Tests\User;
use FactoryArgTestTrait;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserGroupManagerFactory;
use MediaWikiUnitTestCase;
use ReflectionParameter;
use Wikimedia\Rdbms\ILBFactory;
use Wikimedia\Rdbms\ILoadBalancer;
/**
* @covers \MediaWiki\User\UserGroupManagerFactory
*/
class UserGroupManagerFactoryTest extends MediaWikiUnitTestCase {
use FactoryArgTestTrait;
protected static function getFactoryClass() {
return UserGroupManagerFactory::class;
}
protected static function getInstanceClass() {
return UserGroupManager::class;
}
protected static function getExtraClassArgCount() {
return 1;
}
protected function getFactoryMethodName() {
return 'getUserGroupManager';
}
protected function getIgnoredParamNames() {
return [ 'hookContainer', 'configuredReadOnlyMode' ];
}
protected function getOverriddenMockValueForParam( ReflectionParameter $param ) {
if ( $param->getType() && $param->getType()->getName() === ILBFactory::class ) {
$mock = $this->createNoOpMock( ILBFactory::class, [ 'getMainLB' ] );
$mock->method( 'getMainLB' )
->willReturn( $this->createNoOpMock( ILoadBalancer::class ) );
return [ $mock ];
}
return [];
}
}