3 use MediaWiki\Auth\AuthManager
;
4 use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest
;
5 use MediaWiki\Block\DatabaseBlock
;
6 use MediaWiki\Block\CompositeBlock
;
7 use MediaWiki\Block\SystemBlock
;
8 use MediaWiki\Config\ServiceOptions
;
9 use MediaWiki\Permissions\PermissionManager
;
10 use Psr\Log\NullLogger
;
11 use Wikimedia\Rdbms\ILoadBalancer
;
14 * @covers PasswordReset
17 class PasswordResetTest
extends MediaWikiTestCase
{
18 const VALID_IP
= '1.2.3.4';
19 const VALID_EMAIL
= 'foo@bar.baz';
22 * @dataProvider provideIsAllowed
24 public function testIsAllowed( $passwordResetRoutes, $enableEmail,
25 $allowsAuthenticationDataChange, $canEditPrivate, $block, $globalBlock, $isAllowed
27 $config = $this->makeConfig( $enableEmail, $passwordResetRoutes, false );
29 $authManager = $this->getMockBuilder( AuthManager
::class )->disableOriginalConstructor()
31 $authManager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' )
32 ->willReturn( $allowsAuthenticationDataChange ? Status
::newGood() : Status
::newFatal( 'foo' ) );
34 $user = $this->getMockBuilder( User
::class )->getMock();
35 $user->expects( $this->any() )->method( 'getName' )->willReturn( 'Foo' );
36 $user->expects( $this->any() )->method( 'getBlock' )->willReturn( $block );
37 $user->expects( $this->any() )->method( 'getGlobalBlock' )->willReturn( $globalBlock );
39 $permissionManager = $this->getMockBuilder( PermissionManager
::class )
40 ->disableOriginalConstructor()
42 $permissionManager->method( 'userHasRight' )
43 ->with( $user, 'editmyprivateinfo' )
44 ->willReturn( $canEditPrivate );
46 $loadBalancer = $this->getMockBuilder( ILoadBalancer
::class )->getMock();
48 $passwordReset = new PasswordReset(
56 $this->assertSame( $isAllowed, $passwordReset->isAllowed( $user )->isGood() );
59 public function provideIsAllowed() {
62 'passwordResetRoutes' => [],
63 'enableEmail' => true,
64 'allowsAuthenticationDataChange' => true,
65 'canEditPrivate' => true,
67 'globalBlock' => null,
71 'passwordResetRoutes' => [ 'username' => true ],
72 'enableEmail' => false,
73 'allowsAuthenticationDataChange' => true,
74 'canEditPrivate' => true,
76 'globalBlock' => null,
79 'auth data change disabled' => [
80 'passwordResetRoutes' => [ 'username' => true ],
81 'enableEmail' => true,
82 'allowsAuthenticationDataChange' => false,
83 'canEditPrivate' => true,
85 'globalBlock' => null,
88 'cannot edit private data' => [
89 'passwordResetRoutes' => [ 'username' => true ],
90 'enableEmail' => true,
91 'allowsAuthenticationDataChange' => true,
92 'canEditPrivate' => false,
94 'globalBlock' => null,
97 'blocked with account creation disabled' => [
98 'passwordResetRoutes' => [ 'username' => true ],
99 'enableEmail' => true,
100 'allowsAuthenticationDataChange' => true,
101 'canEditPrivate' => true,
102 'block' => new DatabaseBlock( [ 'createAccount' => true ] ),
103 'globalBlock' => null,
104 'isAllowed' => false,
106 'blocked w/o account creation disabled' => [
107 'passwordResetRoutes' => [ 'username' => true ],
108 'enableEmail' => true,
109 'allowsAuthenticationDataChange' => true,
110 'canEditPrivate' => true,
111 'block' => new DatabaseBlock( [] ),
112 'globalBlock' => null,
115 'using blocked proxy' => [
116 'passwordResetRoutes' => [ 'username' => true ],
117 'enableEmail' => true,
118 'allowsAuthenticationDataChange' => true,
119 'canEditPrivate' => true,
120 'block' => new SystemBlock(
121 [ 'systemBlock' => 'proxy' ]
123 'globalBlock' => null,
124 'isAllowed' => false,
126 'globally blocked with account creation not disabled' => [
127 'passwordResetRoutes' => [ 'username' => true ],
128 'enableEmail' => true,
129 'allowsAuthenticationDataChange' => true,
130 'canEditPrivate' => true,
132 'globalBlock' => new SystemBlock(
133 [ 'systemBlock' => 'global-block' ]
137 'blocked via wgSoftBlockRanges' => [
138 'passwordResetRoutes' => [ 'username' => true ],
139 'enableEmail' => true,
140 'allowsAuthenticationDataChange' => true,
141 'canEditPrivate' => true,
142 'block' => new SystemBlock(
143 [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ]
145 'globalBlock' => null,
148 'blocked with an unknown system block type' => [
149 'passwordResetRoutes' => [ 'username' => true ],
150 'enableEmail' => true,
151 'allowsAuthenticationDataChange' => true,
152 'canEditPrivate' => true,
153 'block' => new SystemBlock( [ 'systemBlock' => 'unknown' ] ),
154 'globalBlock' => null,
155 'isAllowed' => false,
157 'blocked with multiple blocks, all allowing password reset' => [
158 'passwordResetRoutes' => [ 'username' => true ],
159 'enableEmail' => true,
160 'allowsAuthenticationDataChange' => true,
161 'canEditPrivate' => true,
162 'block' => new CompositeBlock( [
163 'originalBlocks' => [
164 new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
168 'globalBlock' => null,
171 'blocked with multiple blocks, not all allowing password reset' => [
172 'passwordResetRoutes' => [ 'username' => true ],
173 'enableEmail' => true,
174 'allowsAuthenticationDataChange' => true,
175 'canEditPrivate' => true,
176 'block' => new CompositeBlock( [
177 'originalBlocks' => [
178 new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
179 new SystemBlock( [ 'systemBlock' => 'proxy' ] ),
182 'globalBlock' => null,
183 'isAllowed' => false,
186 'passwordResetRoutes' => [ 'username' => true ],
187 'enableEmail' => true,
188 'allowsAuthenticationDataChange' => true,
189 'canEditPrivate' => true,
191 'globalBlock' => null,
198 * @expectedException \LogicException
200 public function testExecute_notAllowed() {
201 $user = $this->getMock( User
::class );
202 /** @var User $user */
204 $passwordReset = $this->getMockBuilder( PasswordReset
::class )
205 ->disableOriginalConstructor()
206 ->setMethods( [ 'isAllowed' ] )
208 $passwordReset->expects( $this->any() )
209 ->method( 'isAllowed' )
211 ->willReturn( Status
::newFatal( 'somestatuscode' ) );
212 /** @var PasswordReset $passwordReset */
214 $passwordReset->execute( $user );
218 * @dataProvider provideExecute
219 * @param string|bool $expectedError
220 * @param ServiceOptions $config
221 * @param User $performingUser
222 * @param PermissionManager $permissionManager
223 * @param AuthManager $authManager
224 * @param string|null $username
225 * @param string|null $email
226 * @param User[] $usersWithEmail
227 * @covers SendPasswordResetEmailUpdate
229 public function testExecute(
231 ServiceOptions
$config,
232 User
$performingUser,
233 PermissionManager
$permissionManager,
234 AuthManager
$authManager,
237 array $usersWithEmail = []
239 // Unregister the hooks for proper unit testing
240 $this->mergeMwGlobalArrayValue( 'wgHooks', [
241 'User::mailPasswordInternal' => [],
242 'SpecialPasswordResetOnSubmit' => [],
245 $loadBalancer = $this->getMockBuilder( ILoadBalancer
::class )
248 $users = $this->makeUsers();
250 $lookupUser = function ( $username ) use ( $users ) {
251 return $users[ $username ] ??
false;
254 $passwordReset = $this->getMockBuilder( PasswordReset
::class )
255 ->setMethods( [ 'getUsersByEmail', 'isAllowed', 'lookupUser' ] )
256 ->setConstructorArgs( [
264 $passwordReset->method( 'getUsersByEmail' )->with( $email )
265 ->willReturn( array_map( $lookupUser, $usersWithEmail ) );
266 $passwordReset->method( 'isAllowed' )
267 ->willReturn( Status
::newGood() );
268 $passwordReset->method( 'lookupUser' )
269 ->willReturnCallback( $lookupUser );
271 /** @var PasswordReset $passwordReset */
272 $status = $passwordReset->execute( $performingUser, $username, $email );
273 $this->assertStatus( $status, $expectedError );
276 public function provideExecute() {
277 $defaultConfig = $this->makeConfig( true, [ 'username' => true, 'email' => true ], false );
278 $emailRequiredConfig = $this->makeConfig( true, [ 'username' => true, 'email' => true ], true );
279 $performingUser = $this->makePerformingUser( self
::VALID_IP
, false );
280 $throttledUser = $this->makePerformingUser( self
::VALID_IP
, true );
281 $permissionManager = $this->makePermissionManager( $performingUser, true );
285 'expectedError' => 'passwordreset-invalidemail',
286 'config' => $defaultConfig,
287 'performingUser' => $throttledUser,
288 'permissionManager' => $permissionManager,
289 'authManager' => $this->makeAuthManager(),
291 'email' => '[invalid email]',
292 'usersWithEmail' => [],
294 'No username, no email' => [
295 'expectedError' => 'passwordreset-nodata',
296 'config' => $defaultConfig,
297 'performingUser' => $throttledUser,
298 'permissionManager' => $permissionManager,
299 'authManager' => $this->makeAuthManager(),
302 'usersWithEmail' => [],
304 'Email route not enabled' => [
305 'expectedError' => 'passwordreset-nodata',
306 'config' => $this->makeConfig( true, [ 'username' => true ], false ),
307 'performingUser' => $throttledUser,
308 'permissionManager' => $permissionManager,
309 'authManager' => $this->makeAuthManager(),
311 'email' => self
::VALID_EMAIL
,
312 'usersWithEmail' => [],
314 'Username route not enabled' => [
315 'expectedError' => 'passwordreset-nodata',
316 'config' => $this->makeConfig( true, [ 'email' => true ], false ),
317 'performingUser' => $throttledUser,
318 'permissionManager' => $permissionManager,
319 'authManager' => $this->makeAuthManager(),
320 'username' => 'User1',
322 'usersWithEmail' => [],
324 'No routes enabled' => [
325 'expectedError' => 'passwordreset-nodata',
326 'config' => $this->makeConfig( true, [], false ),
327 'performingUser' => $throttledUser,
328 'permissionManager' => $permissionManager,
329 'authManager' => $this->makeAuthManager(),
330 'username' => 'User1',
331 'email' => self
::VALID_EMAIL
,
332 'usersWithEmail' => [],
334 'Email reqiured for resets, but is empty' => [
335 'expectedError' => 'passwordreset-username-email-required',
336 'config' => $emailRequiredConfig,
337 'performingUser' => $throttledUser,
338 'permissionManager' => $permissionManager,
339 'authManager' => $this->makeAuthManager(),
340 'username' => 'User1',
342 'usersWithEmail' => [],
344 'Email reqiured for resets, is invalid' => [
345 'expectedError' => 'passwordreset-invalidemail',
346 'config' => $emailRequiredConfig,
347 'performingUser' => $throttledUser,
348 'permissionManager' => $permissionManager,
349 'authManager' => $this->makeAuthManager(),
350 'username' => 'User1',
351 'email' => '[invalid email]',
352 'usersWithEmail' => [],
355 'expectedError' => 'actionthrottledtext',
356 'config' => $defaultConfig,
357 'performingUser' => $throttledUser,
358 'permissionManager' => $permissionManager,
359 'authManager' => $this->makeAuthManager(),
360 'username' => 'User1',
362 'usersWithEmail' => [],
364 'No user by this username' => [
365 'expectedError' => 'nosuchuser',
366 'config' => $defaultConfig,
367 'performingUser' => $performingUser,
368 'permissionManager' => $permissionManager,
369 'authManager' => $this->makeAuthManager(),
370 'username' => 'Nonexistent user',
372 'usersWithEmail' => [],
374 'If no users with this email found, pretend everything is OK' => [
375 'expectedError' => false,
376 'config' => $defaultConfig,
377 'performingUser' => $performingUser,
378 'permissionManager' => $permissionManager,
379 'authManager' => $this->makeAuthManager(),
381 'email' => 'some@not.found.email',
382 'usersWithEmail' => [],
384 'No email for the user' => [
385 'expectedError' => 'noemail',
386 'config' => $defaultConfig,
387 'performingUser' => $performingUser,
388 'permissionManager' => $permissionManager,
389 'authManager' => $this->makeAuthManager(),
390 'username' => 'BadUser',
392 'usersWithEmail' => [],
394 'Email reqiured for resets, no match' => [
395 'expectedError' => false,
396 'config' => $emailRequiredConfig,
397 'performingUser' => $performingUser,
398 'permissionManager' => $permissionManager,
399 'authManager' => $this->makeAuthManager(),
400 'username' => 'User1',
401 'email' => 'some@other.email',
402 'usersWithEmail' => [],
404 "Couldn't determine the performing user's IP" => [
405 'expectedError' => 'badipaddress',
406 'config' => $defaultConfig,
407 'performingUser' => $this->makePerformingUser( null, false ),
408 'permissionManager' => $permissionManager,
409 'authManager' => $this->makeAuthManager(),
410 'username' => 'User1',
412 'usersWithEmail' => [],
414 'User is allowed, but ignored' => [
415 'expectedError' => 'passwordreset-ignored',
416 'config' => $defaultConfig,
417 'performingUser' => $performingUser,
418 'permissionManager' => $permissionManager,
419 'authManager' => $this->makeAuthManager( [ 'User1' ], 0, [ 'User1' ] ),
420 'username' => 'User1',
422 'usersWithEmail' => [],
424 'One of users is ignored' => [
425 'expectedError' => 'passwordreset-ignored',
426 'config' => $defaultConfig,
427 'performingUser' => $performingUser,
428 'permissionManager' => $permissionManager,
429 'authManager' => $this->makeAuthManager( [ 'User1', 'User2' ], 0, [ 'User2' ] ),
431 'email' => self
::VALID_EMAIL
,
432 'usersWithEmail' => [ 'User1', 'User2' ],
434 'User is rejected' => [
435 'expectedError' => 'rejected by test mock',
436 'config' => $defaultConfig,
437 'performingUser' => $performingUser,
438 'permissionManager' => $permissionManager,
439 'authManager' => $this->makeAuthManager(),
440 'username' => 'User1',
442 'usersWithEmail' => [],
444 'One of users is rejected' => [
445 'expectedError' => 'rejected by test mock',
446 'config' => $defaultConfig,
447 'performingUser' => $performingUser,
448 'permissionManager' => $permissionManager,
449 'authManager' => $this->makeAuthManager( [ 'User1' ] ),
451 'email' => self
::VALID_EMAIL
,
452 'usersWithEmail' => [ 'User1', 'User2' ],
454 'Reset one user via password' => [
455 'expectedError' => false,
456 'config' => $defaultConfig,
457 'performingUser' => $performingUser,
458 'permissionManager' => $permissionManager,
459 'authManager' => $this->makeAuthManager( [ 'User1' ], 1 ),
460 'username' => 'User1',
461 'email' => self
::VALID_EMAIL
,
462 // Make sure that only the user specified by username is reset
463 'usersWithEmail' => [ 'User1', 'User2' ],
465 'Reset one user via email' => [
466 'expectedError' => false,
467 'config' => $defaultConfig,
468 'performingUser' => $performingUser,
469 'permissionManager' => $permissionManager,
470 'authManager' => $this->makeAuthManager( [ 'User1' ], 1 ),
472 'email' => self
::VALID_EMAIL
,
473 'usersWithEmail' => [ 'User1' ],
475 'Reset multiple users via email' => [
476 'expectedError' => false,
477 'config' => $defaultConfig,
478 'performingUser' => $performingUser,
479 'permissionManager' => $permissionManager,
480 'authManager' => $this->makeAuthManager( [ 'User1', 'User2' ], 2 ),
482 'email' => self
::VALID_EMAIL
,
483 'usersWithEmail' => [ 'User1', 'User2' ],
485 "Email is required for resets, user didn't opt in" => [
486 'expectedError' => false,
487 'config' => $emailRequiredConfig,
488 'performingUser' => $performingUser,
489 'permissionManager' => $permissionManager,
490 'authManager' => $this->makeAuthManager( [ 'User2' ], 1 ),
491 'username' => 'User2',
492 'email' => self
::VALID_EMAIL
,
493 'usersWithEmail' => [ 'User2' ],
498 private function assertStatus( StatusValue
$status, $error = false ) {
499 if ( $error === false ) {
500 $this->assertTrue( $status->isGood(), 'Expected status to be good' );
502 $this->assertFalse( $status->isGood(), 'Expected status to not be good' );
503 if ( is_string( $error ) ) {
504 $this->assertNotEmpty( $status->getErrors() );
505 $message = $status->getErrors()[0]['message'];
506 if ( $message instanceof MessageSpecifier
) {
507 $message = $message->getKey();
509 $this->assertSame( $error, $message );
514 private function makeConfig( $enableEmail, array $passwordResetRoutes, $emailForResets ) {
516 'AllowRequiringEmailForResets' => $emailForResets,
517 'EnableEmail' => $enableEmail,
518 'PasswordResetRoutes' => $passwordResetRoutes,
521 return new ServiceOptions( PasswordReset
::CONSTRUCTOR_OPTIONS
, $hash );
525 * @param string|null $ip
526 * @param bool $pingLimited
529 private function makePerformingUser( $ip, $pingLimited ) : User
{
530 $request = $this->getMockBuilder( WebRequest
::class )
532 $request->method( 'getIP' )
534 /** @var WebRequest $request */
536 $user = $this->getMockBuilder( User
::class )
537 ->setMethods( [ 'getName', 'pingLimiter', 'getRequest' ] )
540 $user->method( 'getName' )
541 ->willReturn( 'SomeUser' );
542 $user->method( 'pingLimiter' )
543 ->with( 'mailpassword' )
544 ->willReturn( $pingLimited );
545 $user->method( 'getRequest' )
546 ->willReturn( $request );
548 /** @var User $user */
552 private function makePermissionManager( User
$performingUser, $isAllowed ) : PermissionManager
{
553 $permissionManager = $this->getMockBuilder( PermissionManager
::class )
554 ->disableOriginalConstructor()
556 $permissionManager->method( 'userHasRight' )
557 ->with( $performingUser, 'editmyprivateinfo' )
558 ->willReturn( $isAllowed );
560 /** @var PermissionManager $permissionManager */
561 return $permissionManager;
565 * @param string[] $allowed
566 * @param int $numUsersToAuth
567 * @param string[] $ignored
568 * @return AuthManager
570 private function makeAuthManager(
575 $authManager = $this->getMockBuilder( AuthManager
::class )
576 ->disableOriginalConstructor()
578 $authManager->method( 'allowsAuthenticationDataChange' )
579 ->willReturnCallback(
580 function ( TemporaryPasswordAuthenticationRequest
$req ) use ( $allowed, $ignored ) {
581 $value = in_array( $req->username
, $ignored, true )
584 return in_array( $req->username
, $allowed, true )
585 ? Status
::newGood( $value )
586 : Status
::newFatal( 'rejected by test mock' );
588 // changeAuthenticationData is executed in the deferred update class
589 // SendPasswordResetEmailUpdate
590 $authManager->expects( $this->exactly( $numUsersToAuth ) )
591 ->method( 'changeAuthenticationData' );
593 /** @var AuthManager $authManager */
600 private function makeUsers() {
601 $user1 = $this->getMockBuilder( User
::class )->getMock();
602 $user2 = $this->getMockBuilder( User
::class )->getMock();
603 $user1->method( 'getName' )->willReturn( 'User1' );
604 $user2->method( 'getName' )->willReturn( 'User2' );
605 $user1->method( 'getId' )->willReturn( 1 );
606 $user2->method( 'getId' )->willReturn( 2 );
607 $user1->method( 'getEmail' )->willReturn( self
::VALID_EMAIL
);
608 $user2->method( 'getEmail' )->willReturn( self
::VALID_EMAIL
);
610 $user1->method( 'getBoolOption' )
611 ->with( 'requireemail' )
612 ->willReturn( true );
614 $badUser = $this->getMockBuilder( User
::class )->getMock();
615 $badUser->method( 'getName' )->willReturn( 'BadUser' );
616 $badUser->method( 'getId' )->willReturn( 3 );
617 $badUser->method( 'getEmail' )->willReturn( null );
622 'BadUser' => $badUser,