There are common use cases to having a group inherit permissions from another group. For example, if you have to have a "confirmed" group that can be manually handed out to grant "autoconfirmed" status, or if you wanted to make the "sysop" group also have "interface-admin" powers. Previously to make this work you needed to either copy all the $wgGroupPermission entries for the second group, or use a $wgExtensionFunctions to copy it over at runtime. Neither are great solutions, hence this patch. This introduces a new configuration option, $wgGroupInheritsPermissions, that GroupPermissionsLookup will use when determining what permissions each group has. This option is not recursive for simplicity. To make this work, Special:ListGroupRights now consults GroupPermissionsLookup instead of looking at the $wgGroupPermissions/$wgRevokePermissions globals. It also uses UserGroupManager to get the list of all groups instead of looking at more globals. Anything still directly reading permissions from those globals is liable to be broken, if they weren't already. Bug: T275334 Change-Id: Iad72e126d2708012e1e403bee066b3017c16226d
485 lines
15 KiB
PHP
485 lines
15 KiB
PHP
<?php
|
|
|
|
namespace MediaWiki\Tests\Unit\Permissions;
|
|
|
|
use MediaWiki\Block\BlockErrorFormatter;
|
|
use MediaWiki\Config\ServiceOptions;
|
|
use MediaWiki\HookContainer\HookContainer;
|
|
use MediaWiki\Page\RedirectLookup;
|
|
use MediaWiki\Permissions\GroupPermissionsLookup;
|
|
use MediaWiki\Permissions\PermissionManager;
|
|
use MediaWiki\SpecialPage\SpecialPageFactory;
|
|
use MediaWiki\Tests\Unit\DummyServicesTrait;
|
|
use MediaWiki\User\UserGroupManager;
|
|
use MediaWikiUnitTestCase;
|
|
use Title;
|
|
use User;
|
|
use UserCache;
|
|
use Wikimedia\TestingAccessWrapper;
|
|
|
|
/**
|
|
* @author DannyS712
|
|
*
|
|
* See \MediaWiki\Tests\Integration\Permissions\PermissionManagerTest
|
|
* for integration tests
|
|
*
|
|
* @covers \MediaWiki\Permissions\PermissionManager
|
|
*/
|
|
class PermissionManagerTest extends MediaWikiUnitTestCase {
|
|
use DummyServicesTrait;
|
|
|
|
private function getPermissionManager( $options = [] ) {
|
|
$overrideConfig = $options['config'] ?? [];
|
|
$baseConfig = [
|
|
'WhitelistRead' => false,
|
|
'WhitelistReadRegexp' => false,
|
|
'EmailConfirmToEdit' => false,
|
|
'BlockDisablesLogin' => false,
|
|
'EnablePartialActionBlocks' => false,
|
|
'GroupPermissions' => [],
|
|
'RevokePermissions' => [],
|
|
'GroupInheritsPermissions' => [],
|
|
'AvailableRights' => [],
|
|
'NamespaceProtection' => [ NS_MEDIAWIKI => 'editinterface' ],
|
|
'RestrictionLevels' => [ '', 'autoconfirmed', 'sysop' ],
|
|
'DeleteRevisionsLimit' => false,
|
|
];
|
|
$config = $overrideConfig + $baseConfig;
|
|
$specialPageFactory = $options['specialPageFactory'] ??
|
|
$this->createMock( SpecialPageFactory::class );
|
|
|
|
// DummyServicesTrait::getDummyNamespaceInfo
|
|
$namespaceInfo = $this->getDummyNamespaceInfo();
|
|
|
|
$groupPermissionsLookup = $options['groupPermissionsLookup'] ??
|
|
new GroupPermissionsLookup(
|
|
new ServiceOptions( GroupPermissionsLookup::CONSTRUCTOR_OPTIONS, $config )
|
|
);
|
|
$userGroupManager = $options['userGroupManager'] ??
|
|
$this->createMock( UserGroupManager::class );
|
|
$blockErrorFormatter = $options['blockErrorFormatter'] ??
|
|
$this->createMock( BlockErrorFormatter::class );
|
|
$hookContainer = $options['hookContainer'] ??
|
|
$this->createMock( HookContainer::class );
|
|
$userCache = $options['userCache'] ??
|
|
$this->createMock( UserCache::class );
|
|
$redirectLookup = $options['redirectLookup'] ??
|
|
$this->createMock( RedirectLookup::class );
|
|
|
|
$permissionManager = new PermissionManager(
|
|
new ServiceOptions( PermissionManager::CONSTRUCTOR_OPTIONS, $config ),
|
|
$specialPageFactory,
|
|
$namespaceInfo,
|
|
$groupPermissionsLookup,
|
|
$userGroupManager,
|
|
$blockErrorFormatter,
|
|
$hookContainer,
|
|
$userCache,
|
|
$redirectLookup
|
|
);
|
|
|
|
$accessPermissionManager = TestingAccessWrapper::newFromObject( $permissionManager );
|
|
return $accessPermissionManager;
|
|
}
|
|
|
|
/**
|
|
* @covers \MediaWiki\Permissions\PermissionManager::checkUserConfigPermissions
|
|
*
|
|
* Does not include testing the `editmyuserjsredirect` functionality, that is covered
|
|
* in testCheckUserConfigPermissionsForRedirect
|
|
*
|
|
* @dataProvider provideTestCheckUserConfigPermissions
|
|
* @param string $pageTitle Does not include the namespace prefix
|
|
* @param array $rights What rights the user should be given
|
|
* @param string $action
|
|
* @param string|bool $pageType 'css', 'js', 'json', or false for none of those
|
|
* @param array $expectedErrors
|
|
*/
|
|
public function testCheckUserConfigPermissions(
|
|
string $pageTitle,
|
|
array $rights,
|
|
string $action,
|
|
$pageType,
|
|
array $expectedErrors
|
|
) {
|
|
$user = $this->createMock( User::class );
|
|
$user->method( 'getId' )->willReturn( 123 );
|
|
$user->method( 'getName' )->willReturn( 'NameOfActingUser' );
|
|
|
|
$title = $this->createMock( Title::class );
|
|
$title->method( 'getText' )->willReturn( $pageTitle );
|
|
$title->method( 'isUserCssConfigPage' )->willReturn( $pageType === 'css' );
|
|
$title->method( 'isUserJsonConfigPage' )->willReturn( $pageType === 'json' );
|
|
$title->method( 'isUserJsConfigPage' )->willReturn( $pageType === 'js' );
|
|
|
|
$permissionManager = $this->getPermissionManager();
|
|
// Override user rights
|
|
$permissionManager->overrideUserRightsForTesting( $user, $rights );
|
|
|
|
$result = $permissionManager->checkUserConfigPermissions(
|
|
$action,
|
|
$user,
|
|
[], // starting errors
|
|
PermissionManager::RIGOR_QUICK, // unused
|
|
true, // $short, unused
|
|
$title
|
|
);
|
|
$this->assertEquals( $expectedErrors, $result );
|
|
}
|
|
|
|
public function provideTestCheckUserConfigPermissions() {
|
|
yield 'Patrol ignored' => [ 'NameOfActingUser/subpage', [], 'patrol', false, [] ];
|
|
yield 'Own non-config' => [ 'NameOfActingUser/subpage', [], 'edit', false, [] ];
|
|
yield 'Other non-config' => [ 'NameOfAnotherUser/subpage', [], 'edit', false, [] ];
|
|
yield 'Delete other subpage' => [ 'NameOfAnotherUser/subpage', [], 'delete', false, [] ];
|
|
|
|
foreach ( [ 'css', 'json', 'js' ] as $type ) {
|
|
// User editing their own subpages, everything okay
|
|
// ensure that we don't run the checks for redirects now, those are done separately
|
|
yield "Own $type with editmyuser*" => [
|
|
'NameOfActingUser/subpage.' . $type,
|
|
[ "editmyuser{$type}", 'editmyuserjsredirect' ],
|
|
'edit',
|
|
$type,
|
|
[]
|
|
];
|
|
|
|
// Interface admin editing own subpages, everything okay
|
|
yield "Own $type with edituser*" => [
|
|
'NameOfActingUser/subpage.' . $type,
|
|
[ "edituser{$type}" ],
|
|
'edit',
|
|
$type,
|
|
[]
|
|
];
|
|
|
|
// User with no rights editing own subpages, problematic
|
|
yield "Own $type with no rights" => [
|
|
'NameOfActingUser/subpage.' . $type,
|
|
[],
|
|
'edit',
|
|
$type,
|
|
[ [ "mycustom{$type}protected", 'edit' ] ]
|
|
];
|
|
|
|
// Interface admin editing other user's subpages, everything okay
|
|
yield "Other $type with edituser*" => [
|
|
'NameOfAnotherUser/subpage.' . $type,
|
|
[ "edituser{$type}" ],
|
|
'edit',
|
|
$type,
|
|
[]
|
|
];
|
|
|
|
// Normal user editing other user's subpages, problematic
|
|
yield "Other $type with no rights" => [
|
|
'NameOfAnotherUser/subpage.' . $type,
|
|
[],
|
|
'edit',
|
|
$type,
|
|
[ [ "custom{$type}protected", 'edit' ] ]
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @covers \MediaWiki\Permissions\PermissionManager::checkUserConfigPermissions
|
|
*
|
|
* @dataProvider provideTestCheckUserConfigPermissionsForRedirect
|
|
*/
|
|
public function testCheckUserConfigPermissionsForRedirect(
|
|
bool $canEditOwnRedirect,
|
|
bool $isRedirect,
|
|
int $targetNamespace,
|
|
string $targetText,
|
|
bool $expectErrors
|
|
) {
|
|
$user = $this->createMock( User::class );
|
|
$user->method( 'getId' )->willReturn( 123 );
|
|
$user->method( 'getName' )->willReturn( 'NameOfActingUser' );
|
|
|
|
$title = $this->createMock( Title::class );
|
|
$title->method( 'getText' )->willReturn( 'NameOfActingUser/common.js' );
|
|
$title->method( 'isUserCssConfigPage' )->willReturn( false );
|
|
$title->method( 'isUserJsonConfigPage' )->willReturn( false );
|
|
$title->method( 'isUserJsConfigPage' )->willReturn( true );
|
|
|
|
if ( $isRedirect ) {
|
|
$target = $this->createMock( Title::class );
|
|
$target->method( 'inNamespace' )
|
|
->with( NS_USER )
|
|
->willReturn( $targetNamespace === NS_USER );
|
|
|
|
$target->method( 'getText' )->willReturn( $targetText );
|
|
} else {
|
|
$target = null;
|
|
}
|
|
|
|
$redirectLookup = $this->createMock( RedirectLookup::class );
|
|
$redirectLookup->method( 'getRedirectTarget' )->with( $title )
|
|
->willReturn( $target );
|
|
|
|
$permissionManager = $this->getPermissionManager( [
|
|
'redirectLookup' => $redirectLookup,
|
|
] );
|
|
|
|
// Override user rights
|
|
$rights = [ 'editmyuserjs' ];
|
|
if ( $canEditOwnRedirect ) {
|
|
$rights[] = 'editmyuserjsredirect';
|
|
}
|
|
$permissionManager->overrideUserRightsForTesting( $user, $rights );
|
|
|
|
$result = $permissionManager->checkUserConfigPermissions(
|
|
'edit',
|
|
$user,
|
|
[], // starting errors
|
|
PermissionManager::RIGOR_QUICK, // unused
|
|
true, // $short, unused
|
|
$title
|
|
);
|
|
$this->assertEquals(
|
|
$expectErrors ? [ [ 'mycustomjsredirectprotected', 'edit' ] ] : [],
|
|
$result
|
|
);
|
|
}
|
|
|
|
public function provideTestCheckUserConfigPermissionsForRedirect() {
|
|
yield 'With `editmyuserjsredirect`' => [ true, true, NS_USER, 'NameOfActingUser/other.js', false ];
|
|
yield 'Not a redirect' => [ false, false, NS_USER, 'NameOfActingUser/other.js', false ];
|
|
yield 'Redirect out of user space' => [ false, true, NS_MAIN, 'MainPage.js', true ];
|
|
yield 'Redirect to different user' => [ false, true, NS_USER, 'NameOfAnotherUser/other.js', true ];
|
|
yield 'Redirect to own subpage' => [ false, true, NS_USER, 'NameOfActingUser/other.js', false ];
|
|
}
|
|
|
|
/**
|
|
* @covers \MediaWiki\Permissions\PermissionManager::checkPageRestrictions
|
|
*
|
|
* @dataProvider provideTestCheckPageRestrictions
|
|
*/
|
|
public function testCheckPageRestrictions(
|
|
string $action,
|
|
array $restrictions,
|
|
array $rights,
|
|
bool $cascading,
|
|
array $expectedErrors
|
|
) {
|
|
$user = $this->createMock( User::class );
|
|
$user->method( 'getId' )->willReturn( 123 );
|
|
$user->method( 'getName' )->willReturn( 'NameOfActingUser' );
|
|
|
|
$title = $this->createMock( Title::class );
|
|
$title->expects( $this->once() )
|
|
->method( 'getRestrictions' )
|
|
->with( $action )
|
|
->willReturn( $restrictions );
|
|
$title->method( 'areRestrictionsCascading' )->willReturn( $cascading );
|
|
|
|
$permissionManager = $this->getPermissionManager();
|
|
$permissionManager->overrideUserRightsForTesting( $user, $rights );
|
|
|
|
$result = $permissionManager->checkPageRestrictions(
|
|
$action,
|
|
$user,
|
|
[], // starting errors
|
|
PermissionManager::RIGOR_QUICK, // unused
|
|
true, // $short, unused
|
|
$title
|
|
);
|
|
$this->assertEquals( $expectedErrors, $result );
|
|
}
|
|
|
|
public function provideTestCheckPageRestrictions() {
|
|
yield 'No restrictions' => [ 'move', [], [], true, [] ];
|
|
yield 'Empty string' => [ 'edit', [ '' ], [], true, [] ];
|
|
yield 'Semi-protected, with rights' => [
|
|
'edit',
|
|
[ 'autoconfirmed' ],
|
|
[ 'editsemiprotected' ],
|
|
false,
|
|
[]
|
|
];
|
|
yield 'Sysop protected, no rights' => [
|
|
'edit',
|
|
[ 'sysop', ],
|
|
[],
|
|
false,
|
|
[ [ 'protectedpagetext', 'editprotected', 'edit' ] ]
|
|
];
|
|
yield 'Sysop protected and cascading, no protect' => [
|
|
'edit',
|
|
[ 'editprotected' ],
|
|
[ 'editprotected' ],
|
|
true,
|
|
[ [ 'protectedpagetext', 'protect', 'edit' ] ]
|
|
];
|
|
yield 'Sysop protected and cascading, with rights' => [
|
|
'edit',
|
|
[ 'editprotected' ],
|
|
[ 'editprotected', 'protect' ],
|
|
true,
|
|
[]
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @covers \MediaWiki\Permissions\PermissionManager::checkQuickPermissions
|
|
*
|
|
* @dataProvider provideTestCheckQuickPermissions
|
|
*/
|
|
public function testCheckQuickPermissions(
|
|
int $namespace,
|
|
string $pageTitle,
|
|
bool $userIsAnon,
|
|
string $action,
|
|
array $rights,
|
|
string $expectedError
|
|
) {
|
|
// Convert string single error to the array of errors PermissionManager uses
|
|
$expectedErrors = ( $expectedError === '' ? [] : [ [ $expectedError ] ] );
|
|
|
|
$user = $this->createMock( User::class );
|
|
$user->method( 'getId' )->willReturn( $userIsAnon ? 0 : 123 );
|
|
$user->method( 'getName' )->willReturn( $userIsAnon ? '1.1.1.1' : 'NameOfActingUser' );
|
|
$user->method( 'isAnon' )->willReturn( $userIsAnon );
|
|
|
|
// HookContainer - always return true (false tested separately)
|
|
$hookContainer = $this->createMock( HookContainer::class );
|
|
$hookContainer->method( 'run' )
|
|
->willReturn( true );
|
|
|
|
// Overrides needed in case `groupHasPermission` is called
|
|
$config = [
|
|
'GroupPermissions' => [
|
|
'autoconfirmed' => [
|
|
'move' => true
|
|
]
|
|
]
|
|
];
|
|
|
|
$permissionManager = $this->getPermissionManager( [
|
|
'config' => $config,
|
|
'hookContainer' => $hookContainer,
|
|
] );
|
|
$permissionManager->overrideUserRightsForTesting( $user, $rights );
|
|
|
|
$title = $this->createMock( Title::class );
|
|
$title->method( 'getNamespace' )->willReturn( $namespace );
|
|
$title->method( 'getText' )->willReturn( $pageTitle );
|
|
|
|
// Ensure that `missingPermissionError` doesn't call User::newFatalPermissionDeniedStatus
|
|
// which uses the global state
|
|
$short = true;
|
|
|
|
$result = $permissionManager->checkQuickPermissions(
|
|
$action,
|
|
$user,
|
|
[], // Starting errors
|
|
PermissionManager::RIGOR_QUICK, // unused
|
|
$short,
|
|
$title
|
|
);
|
|
$this->assertEquals( $expectedErrors, $result );
|
|
}
|
|
|
|
public function provideTestCheckQuickPermissions() {
|
|
// $namespace, $pageTitle, $userIsAnon, $action, $rights, $expectedError
|
|
// Four different possible errors when trying to create
|
|
yield 'Anon createtalk fail' => [
|
|
NS_TALK, 'Example', true, 'create', [], 'nocreatetext'
|
|
];
|
|
yield 'Anon createpage fail' => [
|
|
NS_MAIN, 'Example', true, 'create', [], 'nocreatetext'
|
|
];
|
|
yield 'User createtalk fail' => [
|
|
NS_TALK, 'Example', false, 'create', [], 'nocreate-loggedin'
|
|
];
|
|
yield 'User createpage fail' => [
|
|
NS_MAIN, 'Example', false, 'create', [], 'nocreate-loggedin'
|
|
];
|
|
yield 'Createpage pass' => [
|
|
NS_MAIN, 'Example', true, 'create', [ 'createpage' ], ''
|
|
];
|
|
|
|
// Three different namespace specific move failures, even if user has `move` rights
|
|
yield 'Move root user page fail' => [
|
|
NS_USER, 'Example', true, 'move', [ 'move' ], 'cant-move-user-page'
|
|
];
|
|
yield 'Move file fail' => [
|
|
NS_FILE, 'Example', true, 'move', [ 'move' ], 'movenotallowedfile'
|
|
];
|
|
yield 'Move category fail' => [
|
|
NS_CATEGORY, 'Example', true, 'move', [ 'move' ], 'cant-move-category-page'
|
|
];
|
|
|
|
// No move rights at all. Different failures depending on who is allowed to move.
|
|
// Test method sets group permissions to [ 'autoconfirmed' => [ 'move' => true ] ]
|
|
yield 'Anon move fail, autoconfirmed can move' => [
|
|
NS_TALK, 'Example', true, 'move', [], 'movenologintext'
|
|
];
|
|
yield 'User move fail, autoconfirmed can move' => [
|
|
NS_TALK, 'Example', false, 'move', [], 'movenotallowed'
|
|
];
|
|
yield 'Move pass' => [ NS_MAIN, 'Example', true, 'move', [ 'move' ], '' ];
|
|
|
|
// Three different possible failures for move target
|
|
yield 'Move-target no rights' => [
|
|
NS_MAIN, 'Example', false, 'move-target', [], 'movenotallowed'
|
|
];
|
|
yield 'Move-target to user root' => [
|
|
NS_USER, 'Example', false, 'move-target', [ 'move' ], 'cant-move-to-user-page'
|
|
];
|
|
yield 'Move-target to category' => [
|
|
NS_CATEGORY, 'Example', false, 'move-target', [ 'move' ], 'cant-move-to-category-page'
|
|
];
|
|
yield 'Move-target pass' => [
|
|
NS_MAIN, 'Example', false, 'move-target', [ 'move' ], ''
|
|
];
|
|
|
|
// Other actions without special handling
|
|
yield 'Missing rights for edit' => [
|
|
NS_MAIN, 'Example', false, 'edit', [], 'badaccess-group0'
|
|
];
|
|
yield 'Having rights for edit' => [
|
|
NS_MAIN, 'Example', false, 'edit', [ 'edit', ], ''
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @covers \MediaWiki\Permissions\PermissionManager::checkQuickPermissions
|
|
*/
|
|
public function testCheckQuickPermissionsHook() {
|
|
$title = $this->createMock( Title::class );
|
|
$user = $this->createMock( User::class );
|
|
$action = 'FakeActionGoesHere';
|
|
|
|
$hookCallback = function ( $hookTitle, $hookUser, $hookAction, &$errors, $doExpensiveQueries, $short )
|
|
use ( $user, $title, $action )
|
|
{
|
|
$this->assertSame( $title, $hookTitle );
|
|
$this->assertSame( $user, $hookUser );
|
|
$this->assertSame( $action, $hookAction );
|
|
$errors[] = [ 'Hook failure goes here' ];
|
|
return false;
|
|
};
|
|
|
|
$hookContainer = $this->createHookContainer( [ 'TitleQuickPermissions' => $hookCallback ] );
|
|
|
|
$permissionManager = $this->getPermissionManager( [
|
|
'hookContainer' => $hookContainer,
|
|
] );
|
|
$result = $permissionManager->checkQuickPermissions(
|
|
$action,
|
|
$user,
|
|
[], // Starting errors
|
|
PermissionManager::RIGOR_QUICK, // unused
|
|
true, // $short, unused,
|
|
$title
|
|
);
|
|
$this->assertEquals(
|
|
[ [ 'Hook failure goes here' ] ],
|
|
$result
|
|
);
|
|
}
|
|
|
|
}
|