3 use MediaWiki\Auth\AuthManager
;
4 use MediaWiki\Config\Config
;
5 use MediaWiki\Config\ServiceOptions
;
6 use MediaWiki\Context\IContextSource
;
7 use MediaWiki\Context\RequestContext
;
8 use MediaWiki\HookContainer\HookContainer
;
9 use MediaWiki\HookContainer\HookRunner
;
10 use MediaWiki\Language\ILanguageConverter
;
11 use MediaWiki\Language\Language
;
12 use MediaWiki\Languages\LanguageConverterFactory
;
13 use MediaWiki\Languages\LanguageNameUtils
;
14 use MediaWiki\Linker\LinkRenderer
;
15 use MediaWiki\MainConfigNames
;
16 use MediaWiki\MediaWikiServices
;
17 use MediaWiki\Parser\ParserFactory
;
18 use MediaWiki\Permissions\PermissionManager
;
19 use MediaWiki\Preferences\DefaultPreferencesFactory
;
20 use MediaWiki\Preferences\SignatureValidatorFactory
;
21 use MediaWiki\Request\FauxRequest
;
22 use MediaWiki\Session\SessionId
;
23 use MediaWiki\Tests\Session\TestUtils
;
24 use MediaWiki\Tests\Unit\DummyServicesTrait
;
25 use MediaWiki\Title\NamespaceInfo
;
26 use MediaWiki\Title\Title
;
27 use MediaWiki\User\Options\UserOptionsLookup
;
28 use MediaWiki\User\Options\UserOptionsManager
;
29 use MediaWiki\User\User
;
30 use MediaWiki\User\UserGroupManager
;
31 use MediaWiki\User\UserGroupMembership
;
32 use MediaWiki\User\UserIdentity
;
33 use PHPUnit\Framework\MockObject\MockObject
;
34 use Wikimedia\TestingAccessWrapper
;
37 * This program is free software; you can redistribute it and/or modify
38 * it under the terms of the GNU General Public License as published by
39 * the Free Software Foundation; either version 2 of the License, or
40 * (at your option) any later version.
42 * This program is distributed in the hope that it will be useful,
43 * but WITHOUT ANY WARRANTY; without even the implied warranty of
44 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
45 * GNU General Public License for more details.
47 * You should have received a copy of the GNU General Public License along
48 * with this program; if not, write to the Free Software Foundation, Inc.,
49 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
50 * http://www.gnu.org/copyleft/gpl.html
58 * @coversDefaultClass \MediaWiki\Preferences\DefaultPreferencesFactory
60 class DefaultPreferencesFactoryTest
extends \MediaWikiIntegrationTestCase
{
61 use DummyServicesTrait
;
62 use TestAllServiceOptionsUsed
;
64 /** @var IContextSource */
70 protected function setUp(): void
{
72 $this->context
= new RequestContext();
73 $this->context
->setTitle( Title
::makeTitle( NS_MAIN
, self
::class ) );
75 $this->overrideConfigValues( [
76 MainConfigNames
::DisableLangConversion
=> false,
77 MainConfigNames
::UsePigLatinVariant
=> false,
79 $this->config
= $this->getServiceContainer()->getMainConfig();
83 * @covers ::__construct
85 public function testConstruct() {
86 // Make sure if the optional services are not provided, stuff still works, so that
87 // the GlobalPreferences extension isn't broken
89 $this->createMock( ServiceOptions
::class ),
90 $this->createMock( Language
::class ),
91 $this->createMock( AuthManager
::class ),
92 $this->createMock( LinkRenderer
::class ),
93 $this->createMock( NamespaceInfo
::class ),
94 $this->createMock( PermissionManager
::class ),
95 $this->createMock( ILanguageConverter
::class ),
96 $this->createMock( LanguageNameUtils
::class ),
97 $this->createMock( HookContainer
::class ),
98 $this->createMock( UserOptionsLookup
::class ),
100 $preferencesFactory = new DefaultPreferencesFactory( ...$params );
101 $this->assertInstanceOf(
102 DefaultPreferencesFactory
::class,
104 'Created with some services missing'
107 // Now, make sure that MediaWikiServices isn't used
108 // Switch the UserOptionsLookup to a UserOptionsManager
109 $params[9] = $this->createMock( UserOptionsManager
::class );
110 $params[] = $this->createMock( LanguageConverterFactory
::class );
111 $params[] = $this->createMock( ParserFactory
::class );
112 $params[] = $this->createMock( SkinFactory
::class );
113 $params[] = $this->createMock( UserGroupManager
::class );
114 $params[] = $this->createMock( SignatureValidatorFactory
::class );
115 $oldMwServices = MediaWikiServices
::forceGlobalInstance(
116 $this->createNoOpMock( MediaWikiServices
::class )
118 // Wrap in a try-finally block to make sure the real MediaWikiServices is
119 // always put back even if something goes wrong
121 $preferencesFactory = new DefaultPreferencesFactory( ...$params );
122 $this->assertInstanceOf(
123 DefaultPreferencesFactory
::class,
125 'Created with all services, MediaWikiServices not used'
128 // Put back the real MediaWikiServices
129 MediaWikiServices
::forceGlobalInstance( $oldMwServices );
134 * Get a basic PreferencesFactory for testing with.
135 * @param array $options Supported options are:
136 * 'language' - A Language object, falls back to content language
137 * 'userOptionsManager' - A UserOptionsManager service, falls back to using MediaWikiServices
138 * 'userGroupManager' - A UserGroupManager service, falls back to a mock where no users
139 * have any extra groups, just `*` and `user`
140 * @return DefaultPreferencesFactory
142 protected function getPreferencesFactory( array $options = [] ) {
143 $nsInfo = $this->getDummyNamespaceInfo();
145 $services = $this->getServiceContainer();
147 // The PermissionManager should not be used for anything, its only a parameter
148 // until we figure out how to remove it without breaking the GlobalPreferences
149 // extension (GlobalPreferencesFactory extends DefaultPreferencesFactory)
150 $permissionManager = $this->createNoOpMock( PermissionManager
::class );
152 $language = $options['language'] ??
$services->getContentLanguage();
153 $userOptionsManager = $options['userOptionsManager'] ??
$services->getUserOptionsManager();
155 $userGroupManager = $options['userGroupManager'] ??
false;
156 if ( !$userGroupManager ) {
157 $userGroupManager = $this->createMock( UserGroupManager
::class );
158 $userGroupManager->method( 'getUserGroupMemberships' )->willReturn( [] );
159 $userGroupManager->method( 'getUserEffectiveGroups' )->willReturnCallback(
160 static function ( UserIdentity
$user ) {
161 return $user->isRegistered() ?
[ '*', 'user' ] : [ '*' ];
166 return new DefaultPreferencesFactory(
167 new LoggedServiceOptions( self
::$serviceOptionsAccessLog,
168 DefaultPreferencesFactory
::CONSTRUCTOR_OPTIONS
, $this->config
),
170 $services->getAuthManager(),
171 $services->getLinkRenderer(),
174 $services->getLanguageConverterFactory()->getLanguageConverter( $language ),
175 $services->getLanguageNameUtils(),
176 $services->getHookContainer(),
178 $services->getLanguageConverterFactory(),
179 $services->getParserFactory(),
180 $services->getSkinFactory(),
182 $services->getSignatureValidatorFactory()
188 * @covers ::searchPreferences
190 public function testGetForm() {
191 $this->setTemporaryHook( 'GetPreferences', HookContainer
::NOOP
);
193 $testUser = $this->createMock( User
::class );
194 $prefFactory = $this->getPreferencesFactory();
195 $form = $prefFactory->getForm( $testUser, $this->context
);
196 $this->assertInstanceOf( PreferencesFormOOUI
::class, $form );
197 $this->assertCount( 6, $form->getPreferenceSections() );
201 * @covers ::sortSkinNames
203 public function testSortSkinNames() {
204 /** @var DefaultPreferencesFactory $factory */
205 $factory = TestingAccessWrapper
::newFromObject(
206 $this->getPreferencesFactory()
209 'minerva' => 'Minerva Neue',
210 'monobook' => 'Monobook',
211 'cologne-blue' => 'Cologne Blue',
212 'vector' => 'Vector',
213 'vector-2022' => 'Vector 2022',
214 'timeless' => 'Timeless',
216 $currentSkin = 'monobook';
217 $preferredSkins = [ 'vector-2022', 'invalid-skin', 'vector' ];
219 uksort( $validSkinNames, static function ( $a, $b ) use ( $factory, $currentSkin, $preferredSkins ) {
220 return $factory->sortSkinNames( $a, $b, $currentSkin, $preferredSkins );
223 $this->assertArrayEquals( [
224 'monobook' => 'Monobook',
225 'vector-2022' => 'Vector 2022',
226 'vector' => 'Vector',
227 'cologne-blue' => 'Cologne Blue',
228 'minerva' => 'Minerva Neue',
229 'timeless' => 'Timeless',
230 ], $validSkinNames );
234 * CSS classes for emailauthentication preference field when there's no email.
235 * @see https://phabricator.wikimedia.org/T36302
237 * @covers ::profilePreferences
238 * @dataProvider emailAuthenticationProvider
240 public function testEmailAuthentication( $user, $cssClass ) {
241 $this->overrideConfigValue( MainConfigNames
::EmailAuthentication
, true );
243 $prefs = $this->getPreferencesFactory()
244 ->getFormDescriptor( $user, $this->context
);
245 $this->assertArrayHasKey( 'cssclass', $prefs['emailauthentication'] );
246 $this->assertEquals( $cssClass, $prefs['emailauthentication']['cssclass'] );
250 * @covers ::renderingPreferences
252 public function testShowRollbackConfIsHiddenForUsersWithoutRollbackRights() {
253 $userMock = $this->createMock( User
::class );
254 $userMock->method( 'isAllowed' )->willReturnCallback(
255 static function ( $permission ) {
256 return $permission === 'editmyoptions';
260 $userOptionsManagerMock = $this->createUserOptionsManagerMock( [ 'test' => 'yes' ], true );
261 $userMock = $this->getUserMockWithSession( $userMock );
262 $prefs = $this->getPreferencesFactory( [
263 'userOptionsManager' => $userOptionsManagerMock,
264 ] )->getFormDescriptor( $userMock, $this->context
);
265 $this->assertArrayNotHasKey( 'showrollbackconfirmation', $prefs );
269 * @covers ::renderingPreferences
271 public function testShowRollbackConfIsShownForUsersWithRollbackRights() {
272 $userMock = $this->createMock( User
::class );
273 $userMock->method( 'isAllowed' )->willReturnCallback(
274 static function ( $permission ) {
275 return $permission === 'editmyoptions' ||
$permission === 'rollback';
278 $userMock = $this->getUserMockWithSession( $userMock );
280 $userOptionsManagerMock = $this->createUserOptionsManagerMock( [ 'test' => 'yes' ], true );
281 $prefs = $this->getPreferencesFactory( [
282 'userOptionsManager' => $userOptionsManagerMock,
283 ] )->getFormDescriptor( $userMock, $this->context
);
284 $this->assertArrayHasKey( 'showrollbackconfirmation', $prefs );
286 'rendering/advancedrendering',
287 $prefs['showrollbackconfirmation']['section']
291 public function emailAuthenticationProvider() {
292 $userNoEmail = new User
;
293 $userEmailUnauthed = new User
;
294 $userEmailUnauthed->setEmail( 'noauth@example.org' );
295 $userEmailAuthed = new User
;
296 $userEmailAuthed->setEmail( 'noauth@example.org' );
297 $userEmailAuthed->setEmailAuthenticationTimestamp( wfTimestamp() );
299 [ $userNoEmail, 'mw-email-none' ],
300 [ $userEmailUnauthed, 'mw-email-not-authenticated' ],
301 [ $userEmailAuthed, 'mw-email-authenticated' ],
306 * Test that PreferencesFormPreSave hook has correct data:
307 * - user Object is passed
308 * - oldUserOptions contains previous user options (before save)
309 * - formData and User object have set up new properties
311 * @see https://phabricator.wikimedia.org/T169365
312 * @covers ::submitForm
314 public function testPreferencesFormPreSaveHookHasCorrectData() {
324 $this->overrideConfigValue( MainConfigNames
::HiddenPrefs
, [] );
326 $form = $this->createMock( PreferencesFormOOUI
::class );
328 $userMock = $this->createMock( User
::class );
330 $userOptionsManagerMock = $this->createUserOptionsManagerMock( $oldOptions );
331 $expectedOptions = $newOptions;
332 $userOptionsManagerMock->expects( $this->exactly( count( $newOptions ) ) )
333 ->method( 'setOption' )
334 ->willReturnCallback( function ( $user, $oname, $val ) use ( $userMock, &$expectedOptions ) {
335 $this->assertSame( $userMock, $user );
336 $this->assertArrayHasKey( $oname, $expectedOptions );
337 $this->assertSame( $expectedOptions[$oname], $val );
338 unset( $expectedOptions[$oname] );
340 $userMock->method( 'isAllowed' )->willReturnCallback(
341 static function ( $permission ) {
342 return $permission === 'editmyprivateinfo' ||
$permission === 'editmyoptions';
345 $userMock->method( 'isAllowedAny' )->willReturnCallback(
346 static function ( ...$permissions ) {
347 foreach ( $permissions as $perm ) {
348 if ( $perm === 'editmyprivateinfo' ||
$perm === 'editmyoptions' ) {
356 $form->method( 'getModifiedUser' )
357 ->willReturn( $userMock );
359 $form->method( 'getContext' )
360 ->willReturn( $this->context
);
362 $this->setTemporaryHook( 'PreferencesFormPreSave',
364 $formData, $form, $user, &$result, $oldUserOptions
366 $newOptions, $oldOptions, $userMock
368 $this->assertSame( $userMock, $user );
369 foreach ( $newOptions as $option => $value ) {
370 $this->assertSame( $value, $formData[ $option ] );
372 foreach ( $oldOptions as $option => $value ) {
373 $this->assertSame( $value, $oldUserOptions[ $option ] );
375 $this->assertTrue( $result );
379 /** @var DefaultPreferencesFactory $factory */
380 $factory = TestingAccessWrapper
::newFromObject(
381 $this->getPreferencesFactory( [ 'userOptionsManager' => $userOptionsManagerMock ] )
383 $factory->saveFormData( $newOptions, $form, [] );
387 * The rclimit preference should accept non-integer input and filter it to become an integer.
389 * @covers ::saveFormData
391 public function testIntvalFilter() {
392 // Test a string with leading zeros (i.e. not octal) and spaces.
393 $this->context
->getRequest()->setVal( 'wprclimit', ' 0012 ' );
395 $prefFactory = $this->getPreferencesFactory();
396 $form = $prefFactory->getForm( $user, $this->context
);
399 $userOptionsLookup = $this->getServiceContainer()->getUserOptionsLookup();
400 $this->assertEquals( 12, $userOptionsLookup->getOption( $user, 'rclimit' ) );
404 * @covers ::profilePreferences
406 public function testVariantsSupport() {
407 $userMock = $this->createMock( User
::class );
408 $userMock->method( 'isAllowed' )->willReturn( true );
409 $userMock = $this->getUserMockWithSession( $userMock );
411 $language = $this->createMock( Language
::class );
412 $language->method( 'getCode' )
413 ->willReturn( 'sr' );
415 $userOptionsManagerMock = $this->createUserOptionsManagerMock(
416 [ 'LanguageCode' => 'sr', 'variant' => 'sr' ], true
419 $prefs = $this->getPreferencesFactory( [
420 'language' => $language,
421 'userOptionsManager' => $userOptionsManagerMock,
422 ] )->getFormDescriptor( $userMock, $this->context
);
423 $this->assertArrayHasKey( 'default', $prefs['variant'] );
424 $this->assertEquals( 'sr', $prefs['variant']['default'] );
428 * @covers ::profilePreferences
430 public function testUserGroupMemberships() {
431 $userMock = $this->createMock( User
::class );
432 $userMock->method( 'isAllowed' )->willReturn( true );
433 $userMock->method( 'isAllowedAny' )->willReturn( true );
434 $userMock->method( 'isRegistered' )->willReturn( true );
435 $userMock = $this->getUserMockWithSession( $userMock );
437 $language = $this->createMock( Language
::class );
438 $language->method( 'getCode' )
439 ->willReturn( 'en' );
441 $userOptionsManagerMock = $this->createUserOptionsManagerMock( [], true );
443 $prefs = $this->getPreferencesFactory( [
444 'language' => $language,
445 'userOptionsManager' => $userOptionsManagerMock,
446 ] )->getFormDescriptor( $userMock, $this->context
);
447 $this->assertArrayHasKey( 'default', $prefs['usergroups'] );
449 UserGroupMembership
::getLinkHTML( 'user', $this->context
),
450 ( $prefs['usergroups']['default'] )()
457 public function testAllServiceOptionsUsed() {
458 $this->assertAllServiceOptionsUsed( [
459 // Only used when $wgEnotifWatchlist or $wgEnotifUserTalk is true
461 // Only used when $wgEnotifWatchlist or $wgEnotifUserTalk is true
462 'EnotifRevealEditorAddress',
463 // Only used when 'fancysig' preference is enabled
464 'SignatureValidation',
469 * @param array $userOptions
470 * @param bool $defaultOptions
471 * @return UserOptionsManager&MockObject
473 private function createUserOptionsManagerMock( array $userOptions, bool $defaultOptions = false ) {
474 $services = $this->getServiceContainer();
475 $defaults = $services->getMainConfig()->get( MainConfigNames
::DefaultUserOptions
);
476 $defaults['language'] = $services->getContentLanguageCode()->toString();
477 $defaults['skin'] = Skin
::normalizeKey( $services->getMainConfig()->get( MainConfigNames
::DefaultSkin
) );
478 ( new HookRunner( $services->getHookContainer() ) )->onUserGetDefaultOptions( $defaults );
479 $userOptions +
= $defaults;
481 $mock = $this->createMock( UserOptionsManager
::class );
482 $mock->method( 'getOptions' )->willReturn( $userOptions );
483 $mock->method( 'getOption' )->willReturnCallback(
484 static function ( $user, $option ) use ( $userOptions ) {
485 return $userOptions[$option] ??
null;
488 if ( $defaultOptions ) {
489 $mock->method( 'getDefaultOptions' )->willReturn( $defaults );
495 * @param MockObject $userMock
498 private function getUserMockWithSession( MockObject
$userMock ): MockObject
{
499 // We're mocking a stdClass because the Session class is final, and thus not mockable.
500 $mock = $this->getMockBuilder( stdClass
::class )
501 ->addMethods( [ 'getAllowedUserRights', 'deregisterSession', 'getSessionId' ] )
503 $mock->method( 'getSessionId' )->willReturn(
504 new SessionId( str_repeat( 'X', 32 ) )
506 $session = TestUtils
::getDummySession( $mock );
507 $mockRequest = $this->getMockBuilder( FauxRequest
::class )
508 ->onlyMethods( [ 'getSession' ] )
510 $mockRequest->method( 'getSession' )->willReturn( $session );
511 $userMock->method( 'getRequest' )->willReturn( $mockRequest );
512 $userMock->method( 'getTitleKey' )->willReturn( '' );