Localisation updates from https://translatewiki.net.
[mediawiki.git] / tests / phpunit / includes / user / PasswordResetTest.php
blob29cb4ee0bbcf71cf03a1d418b786d611f6f88907
1 <?php
3 use MediaWiki\Auth\AuthManager;
4 use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest;
5 use MediaWiki\Block\CompositeBlock;
6 use MediaWiki\Block\DatabaseBlock;
7 use MediaWiki\Block\SystemBlock;
8 use MediaWiki\Config\ServiceOptions;
9 use MediaWiki\MainConfigNames;
10 use MediaWiki\Request\WebRequest;
11 use MediaWiki\Status\Status;
12 use MediaWiki\Tests\Unit\DummyServicesTrait;
13 use MediaWiki\User\Options\StaticUserOptionsLookup;
14 use MediaWiki\User\Options\UserOptionsLookup;
15 use MediaWiki\User\PasswordReset;
16 use MediaWiki\User\User;
17 use MediaWiki\User\UserFactory;
18 use MediaWiki\User\UserIdentityLookup;
19 use MediaWiki\User\UserNameUtils;
20 use Psr\Log\NullLogger;
22 /**
23 * TODO make this a unit test, all dependencies are injected, but DatabaseBlock::__construct()
24 * can't be used in unit tests.
26 * @covers \MediaWiki\User\PasswordReset
27 * @group Database
29 class PasswordResetTest extends MediaWikiIntegrationTestCase {
30 use DummyServicesTrait;
32 private const VALID_IP = '1.2.3.4';
33 private const VALID_EMAIL = 'foo@bar.baz';
35 /**
36 * @dataProvider provideIsAllowed
38 public function testIsAllowed( $passwordResetRoutes, $enableEmail,
39 $allowsAuthenticationDataChange, $canEditPrivate, $block, $isAllowed
40 ) {
41 $config = $this->makeConfig( $enableEmail, $passwordResetRoutes );
43 $authManager = $this->createMock( AuthManager::class );
44 $authManager->method( 'allowsAuthenticationDataChange' )
45 ->willReturn( $allowsAuthenticationDataChange ? Status::newGood() : Status::newFatal( 'foo' ) );
47 $user = $this->createMock( User::class );
48 $user->method( 'getName' )->willReturn( 'Foo' );
49 $user->method( 'getBlock' )->willReturn( $block );
50 $user->method( 'isAllowed' )->with( 'editmyprivateinfo' )->willReturn( $canEditPrivate );
52 $passwordReset = new PasswordReset(
53 $config,
54 new NullLogger(),
55 $authManager,
56 $this->createHookContainer(),
57 $this->createNoOpMock( UserIdentityLookup::class ),
58 $this->createNoOpMock( UserFactory::class ),
59 $this->createNoOpMock( UserNameUtils::class ),
60 $this->createNoOpMock( UserOptionsLookup::class )
63 $this->assertSame( $isAllowed, $passwordReset->isAllowed( $user )->isGood() );
66 public static function provideIsAllowed() {
67 return [
68 'no routes' => [
69 'passwordResetRoutes' => [],
70 'enableEmail' => true,
71 'allowsAuthenticationDataChange' => true,
72 'canEditPrivate' => true,
73 'block' => null,
74 'isAllowed' => false,
76 'email disabled' => [
77 'passwordResetRoutes' => [ 'username' => true ],
78 'enableEmail' => false,
79 'allowsAuthenticationDataChange' => true,
80 'canEditPrivate' => true,
81 'block' => null,
82 'isAllowed' => false,
84 'auth data change disabled' => [
85 'passwordResetRoutes' => [ 'username' => true ],
86 'enableEmail' => true,
87 'allowsAuthenticationDataChange' => false,
88 'canEditPrivate' => true,
89 'block' => null,
90 'isAllowed' => false,
92 'cannot edit private data' => [
93 'passwordResetRoutes' => [ 'username' => true ],
94 'enableEmail' => true,
95 'allowsAuthenticationDataChange' => true,
96 'canEditPrivate' => false,
97 'block' => null,
98 'isAllowed' => false,
100 'blocked with account creation disabled' => [
101 'passwordResetRoutes' => [ 'username' => true ],
102 'enableEmail' => true,
103 'allowsAuthenticationDataChange' => true,
104 'canEditPrivate' => true,
105 'block' => new DatabaseBlock( [ 'createAccount' => true ] ),
106 'isAllowed' => false,
108 'blocked w/o account creation disabled' => [
109 'passwordResetRoutes' => [ 'username' => true ],
110 'enableEmail' => true,
111 'allowsAuthenticationDataChange' => true,
112 'canEditPrivate' => true,
113 'block' => new DatabaseBlock( [] ),
114 'isAllowed' => true,
116 'using blocked proxy' => [
117 'passwordResetRoutes' => [ 'username' => true ],
118 'enableEmail' => true,
119 'allowsAuthenticationDataChange' => true,
120 'canEditPrivate' => true,
121 'block' => new SystemBlock(
122 [ 'systemBlock' => 'proxy' ]
124 'isAllowed' => false,
126 'globally blocked with account creation not disabled' => [
127 'passwordResetRoutes' => [ 'username' => true ],
128 'enableEmail' => true,
129 'allowsAuthenticationDataChange' => true,
130 'canEditPrivate' => true,
131 'block' => null,
132 'isAllowed' => true,
134 'blocked via wgSoftBlockRanges' => [
135 'passwordResetRoutes' => [ 'username' => true ],
136 'enableEmail' => true,
137 'allowsAuthenticationDataChange' => true,
138 'canEditPrivate' => true,
139 'block' => new SystemBlock(
140 [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ]
142 'isAllowed' => true,
144 'blocked with an unknown system block type' => [
145 'passwordResetRoutes' => [ 'username' => true ],
146 'enableEmail' => true,
147 'allowsAuthenticationDataChange' => true,
148 'canEditPrivate' => true,
149 'block' => new SystemBlock( [ 'systemBlock' => 'unknown' ] ),
150 'isAllowed' => false,
152 'blocked with multiple blocks, all allowing password reset' => [
153 'passwordResetRoutes' => [ 'username' => true ],
154 'enableEmail' => true,
155 'allowsAuthenticationDataChange' => true,
156 'canEditPrivate' => true,
157 'block' => new CompositeBlock( [
158 'originalBlocks' => [
159 new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
160 new DatabaseBlock( [] ),
162 ] ),
163 'isAllowed' => true,
165 'blocked with multiple blocks, not all allowing password reset' => [
166 'passwordResetRoutes' => [ 'username' => true ],
167 'enableEmail' => true,
168 'allowsAuthenticationDataChange' => true,
169 'canEditPrivate' => true,
170 'block' => new CompositeBlock( [
171 'originalBlocks' => [
172 new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
173 new SystemBlock( [ 'systemBlock' => 'proxy' ] ),
175 ] ),
176 'isAllowed' => false,
178 'all OK' => [
179 'passwordResetRoutes' => [ 'username' => true ],
180 'enableEmail' => true,
181 'allowsAuthenticationDataChange' => true,
182 'canEditPrivate' => true,
183 'block' => null,
184 'isAllowed' => true,
189 public function testExecute_notAllowed() {
190 $user = $this->createMock( User::class );
191 /** @var User $user */
193 $passwordReset = $this->getMockBuilder( PasswordReset::class )
194 ->disableOriginalConstructor()
195 ->onlyMethods( [ 'isAllowed' ] )
196 ->getMock();
197 $passwordReset->method( 'isAllowed' )
198 ->with( $user )
199 ->willReturn( Status::newFatal( 'somestatuscode' ) );
200 /** @var PasswordReset $passwordReset */
202 $this->expectException( LogicException::class );
203 $passwordReset->execute( $user );
207 * @dataProvider provideExecute
208 * @param string|bool $expectedError
209 * @param ServiceOptions $config
210 * @param User $performingUser
211 * @param AuthManager $authManager
212 * @param string|null $username
213 * @param string|null $email
214 * @param User[] $usersWithEmail
215 * @covers \MediaWiki\Deferred\SendPasswordResetEmailUpdate
217 public function testExecute(
218 $expectedError,
219 ServiceOptions $config,
220 User $performingUser,
221 AuthManager $authManager,
222 $username = '',
223 $email = '',
224 array $usersWithEmail = []
226 $users = $this->makeUsers();
228 // Only User1 has `requireemail` true, everything else false (so that is the default)
229 $userOptionsLookup = new StaticUserOptionsLookup(
230 [ 'User1' => [ 'requireemail' => true ] ],
231 [ 'requireemail' => false ]
234 // Similar to $lookupUser callback, but with null instead of false
235 $userFactory = $this->createMock( UserFactory::class );
236 $userFactory->method( 'newFromName' )
237 ->willReturnCallback(
238 static function ( $username ) use ( $users ) {
239 return $users[ $username ] ?? null;
243 $userIdentityLookup = $this->createMock( UserIdentityLookup::class );
244 $userFactory->method( 'newFromUserIdentity' )
245 ->willReturnArgument( 0 );
247 $lookupUser = static function ( $username ) use ( $users ) {
248 return $users[ $username ] ?? false;
251 $passwordReset = $this->getMockBuilder( PasswordReset::class )
252 ->onlyMethods( [ 'getUsersByEmail', 'isAllowed' ] )
253 ->setConstructorArgs( [
254 $config,
255 new NullLogger(),
256 $authManager,
257 $this->createHookContainer(),
258 $userIdentityLookup,
259 $userFactory,
260 $this->getDummyUserNameUtils(),
261 $userOptionsLookup
263 ->getMock();
264 $passwordReset->method( 'getUsersByEmail' )->with( $email )
265 ->willReturn( array_map( $lookupUser, $usersWithEmail ) );
266 $passwordReset->method( 'isAllowed' )
267 ->willReturn( Status::newGood() );
269 /** @var PasswordReset $passwordReset */
270 $status = $passwordReset->execute( $performingUser, $username, $email );
272 if ( is_string( $expectedError ) ) {
273 $this->assertStatusError( $expectedError, $status );
274 } elseif ( $expectedError ) {
275 $this->assertStatusNotOk( $status );
276 } else {
277 $this->assertStatusGood( $status );
281 public function provideExecute() {
282 // 'User1' has the 'requireemail' preference set (see testExecute()). Other users do not.
283 $defaultConfig = $this->makeConfig( true, [ 'username' => true, 'email' => true ] );
284 $performingUser = $this->makePerformingUser( self::VALID_IP, false );
285 $throttledUser = $this->makePerformingUser( self::VALID_IP, true );
287 return [
288 'Throttled, pretend everything is ok' => [
289 'expectedError' => false,
290 'config' => $defaultConfig,
291 'performingUser' => $throttledUser,
292 'authManager' => $this->makeAuthManager(),
293 'username' => 'User1',
294 'email' => '',
295 'usersWithEmail' => [],
297 'Throttled, email required for resets, is invalid, pretend everything is ok' => [
298 'expectedError' => false,
299 'config' => $defaultConfig,
300 'performingUser' => $throttledUser,
301 'authManager' => $this->makeAuthManager(),
302 'username' => 'User1',
303 'email' => '[invalid email]',
304 'usersWithEmail' => [],
306 'Invalid email' => [
307 'expectedError' => 'passwordreset-invalidemail',
308 'config' => $defaultConfig,
309 'performingUser' => $performingUser,
310 'authManager' => $this->makeAuthManager(),
311 'username' => '',
312 'email' => '[invalid email]',
313 'usersWithEmail' => [],
315 'No username, no email' => [
316 'expectedError' => 'passwordreset-nodata',
317 'config' => $defaultConfig,
318 'performingUser' => $performingUser,
319 'authManager' => $this->makeAuthManager(),
320 'username' => '',
321 'email' => '',
322 'usersWithEmail' => [],
324 'Email route not enabled' => [
325 'expectedError' => 'passwordreset-nodata',
326 'config' => $this->makeConfig( true, [ 'username' => true ] ),
327 'performingUser' => $performingUser,
328 'authManager' => $this->makeAuthManager(),
329 'username' => '',
330 'email' => self::VALID_EMAIL,
331 'usersWithEmail' => [],
333 'Username route not enabled' => [
334 'expectedError' => 'passwordreset-nodata',
335 'config' => $this->makeConfig( true, [ 'email' => true ] ),
336 'performingUser' => $performingUser,
337 'authManager' => $this->makeAuthManager(),
338 'username' => 'User1',
339 'email' => '',
340 'usersWithEmail' => [],
342 'No routes enabled' => [
343 'expectedError' => 'passwordreset-nodata',
344 'config' => $this->makeConfig( true, [] ),
345 'performingUser' => $performingUser,
346 'authManager' => $this->makeAuthManager(),
347 'username' => 'User1',
348 'email' => self::VALID_EMAIL,
349 'usersWithEmail' => [],
351 'Email required for resets but is empty, pretend everything is OK' => [
352 'expectedError' => false,
353 'config' => $defaultConfig,
354 'performingUser' => $performingUser,
355 'authManager' => $this->makeAuthManager(),
356 'username' => 'User1',
357 'email' => '',
358 'usersWithEmail' => [],
360 'Email required for resets but is invalid' => [
361 'expectedError' => 'passwordreset-invalidemail',
362 'config' => $defaultConfig,
363 'performingUser' => $performingUser,
364 'authManager' => $this->makeAuthManager(),
365 'username' => 'User1',
366 'email' => '[invalid email]',
367 'usersWithEmail' => [],
369 'Password email already sent within 24 hours, pretend everything is ok' => [
370 'expectedError' => false,
371 'config' => $defaultConfig,
372 'performingUser' => $performingUser,
373 'authManager' => $this->makeAuthManager( [ 'User1' ], 0, [], [ 'User1' ] ),
374 'username' => 'User1',
375 'email' => '',
376 'usersWithEmail' => [ 'User1' ],
378 'No user by this username, pretend everything is OK' => [
379 'expectedError' => false,
380 'config' => $defaultConfig,
381 'performingUser' => $performingUser,
382 'authManager' => $this->makeAuthManager(),
383 'username' => 'Nonexistent user',
384 'email' => '',
385 'usersWithEmail' => [],
387 'Username is not valid' => [
388 'expectedError' => 'noname',
389 'config' => $defaultConfig,
390 'performingUser' => $performingUser,
391 'authManager' => $this->makeAuthManager(),
392 'username' => 'Invalid|username',
393 'email' => '',
394 'usersWithEmail' => [],
396 'If no users with this email found, pretend everything is OK' => [
397 'expectedError' => false,
398 'config' => $defaultConfig,
399 'performingUser' => $performingUser,
400 'authManager' => $this->makeAuthManager(),
401 'username' => '',
402 'email' => 'some@not.found.email',
403 'usersWithEmail' => [],
405 'No email for the user, pretend everything is OK' => [
406 'expectedError' => false,
407 'config' => $defaultConfig,
408 'performingUser' => $performingUser,
409 'authManager' => $this->makeAuthManager(),
410 'username' => 'BadUser',
411 'email' => '',
412 'usersWithEmail' => [],
414 'Email required for resets, no match' => [
415 'expectedError' => false,
416 'config' => $defaultConfig,
417 'performingUser' => $performingUser,
418 'authManager' => $this->makeAuthManager(),
419 'username' => 'User1',
420 'email' => 'some@other.email',
421 'usersWithEmail' => [],
423 "Couldn't determine the performing user's IP" => [
424 'expectedError' => 'badipaddress',
425 'config' => $defaultConfig,
426 'performingUser' => $this->makePerformingUser( '', false ),
427 'authManager' => $this->makeAuthManager(),
428 'username' => 'User1',
429 'email' => '',
430 'usersWithEmail' => [],
432 'User is allowed, but ignored' => [
433 'expectedError' => 'passwordreset-ignored',
434 'config' => $defaultConfig,
435 'performingUser' => $performingUser,
436 'authManager' => $this->makeAuthManager( [ 'User2' ], 0, [ 'User2' ] ),
437 'username' => 'User2',
438 'email' => '',
439 'usersWithEmail' => [],
441 'One of users is ignored' => [
442 'expectedError' => 'passwordreset-ignored',
443 'config' => $defaultConfig,
444 'performingUser' => $performingUser,
445 'authManager' => $this->makeAuthManager( [ 'User1', 'User2' ], 0, [ 'User2' ] ),
446 'username' => '',
447 'email' => self::VALID_EMAIL,
448 'usersWithEmail' => [ 'User1', 'User2' ],
450 'User is rejected' => [
451 'expectedError' => 'rejected by test mock',
452 'config' => $defaultConfig,
453 'performingUser' => $performingUser,
454 'authManager' => $this->makeAuthManager(),
455 'username' => 'User2',
456 'email' => '',
457 'usersWithEmail' => [],
459 'One of users is rejected' => [
460 'expectedError' => 'rejected by test mock',
461 'config' => $defaultConfig,
462 'performingUser' => $performingUser,
463 'authManager' => $this->makeAuthManager( [ 'User1' ] ),
464 'username' => '',
465 'email' => self::VALID_EMAIL,
466 'usersWithEmail' => [ 'User1', 'User2' ],
468 'Reset one user via password' => [
469 'expectedError' => false,
470 'config' => $defaultConfig,
471 'performingUser' => $performingUser,
472 'authManager' => $this->makeAuthManager( [ 'User1' ], 1 ),
473 'username' => 'User1',
474 'email' => self::VALID_EMAIL,
475 // Make sure that only the user specified by username is reset
476 'usersWithEmail' => [ 'User1', 'User2' ],
478 'Reset one user via email' => [
479 'expectedError' => false,
480 'config' => $defaultConfig,
481 'performingUser' => $performingUser,
482 'authManager' => $this->makeAuthManager( [ 'User2' ], 1 ),
483 'username' => '',
484 'email' => self::VALID_EMAIL,
485 'usersWithEmail' => [ 'User2' ],
487 'Reset multiple users via email' => [
488 'expectedError' => false,
489 'config' => $defaultConfig,
490 'performingUser' => $performingUser,
491 'authManager' => $this->makeAuthManager( [ 'User2', 'User3' ], 2 ),
492 'username' => '',
493 'email' => self::VALID_EMAIL,
494 'usersWithEmail' => [ 'User2', 'User3' ],
496 "Email is not required for resets, this user didn't opt in" => [
497 'expectedError' => false,
498 'config' => $defaultConfig,
499 'performingUser' => $performingUser,
500 'authManager' => $this->makeAuthManager( [ 'User2' ], 1 ),
501 'username' => 'User2',
502 'email' => self::VALID_EMAIL,
503 'usersWithEmail' => [ 'User2' ],
505 'Reset three users via email that did not opt in, multiple users with same email' => [
506 'expectedError' => false,
507 'config' => $defaultConfig,
508 'performingUser' => $performingUser,
509 'authManager' => $this->makeAuthManager( [ 'User2', 'User3', 'User4' ], 3, [ 'User1' ] ),
510 'username' => '',
511 'email' => self::VALID_EMAIL,
512 'usersWithEmail' => [ 'User1', 'User2', 'User3', 'User4' ],
517 private function makeConfig( $enableEmail, array $passwordResetRoutes ) {
518 $hash = [
519 MainConfigNames::EnableEmail => $enableEmail,
520 MainConfigNames::PasswordResetRoutes => $passwordResetRoutes,
523 return new ServiceOptions( PasswordReset::CONSTRUCTOR_OPTIONS, $hash );
527 * @param string $ip
528 * @param bool $pingLimited
529 * @return User
531 private function makePerformingUser( string $ip, $pingLimited ): User {
532 $request = $this->createMock( WebRequest::class );
533 $request->method( 'getIP' )
534 ->willReturn( $ip );
535 /** @var WebRequest $request */
537 $user = $this->getMockBuilder( User::class )
538 ->onlyMethods( [ 'getName', 'pingLimiter', 'getRequest', 'isAllowed' ] )
539 ->getMock();
541 $user->method( 'getName' )
542 ->willReturn( 'SomeUser' );
543 $user->method( 'pingLimiter' )
544 ->with( 'mailpassword' )
545 ->willReturn( $pingLimited );
546 $user->method( 'getRequest' )
547 ->willReturn( $request );
549 // Always has the relevant rights, just checking based on rate limits
550 $user->method( 'isAllowed' )->with( 'editmyprivateinfo' )->willReturn( true );
552 /** @var User $user */
553 return $user;
557 * @param string[] $allowed Usernames that are allowed to send password reset email
558 * by AuthManager's allowsAuthenticationDataChange method.
559 * @param int $numUsersToAuth Number of users that will receive email
560 * @param string[] $ignored Usernames that are allowed but ignored by AuthManager's
561 * allowsAuthenticationDataChange method and will not receive password reset email.
562 * @param string[] $mailThrottledLimited Usernames that have already
563 * received the password reset email within a given time, and AuthManager
564 * changeAuthenticationData method will mark them as 'throttled-mailpassword.'
565 * @return AuthManager
567 private function makeAuthManager(
568 array $allowed = [],
569 $numUsersToAuth = 0,
570 array $ignored = [],
571 array $mailThrottledLimited = []
572 ): AuthManager {
573 $authManager = $this->createMock( AuthManager::class );
574 $authManager->method( 'allowsAuthenticationDataChange' )
575 ->willReturnCallback(
576 static function ( TemporaryPasswordAuthenticationRequest $req )
577 use ( $allowed, $ignored, $mailThrottledLimited ) {
578 if ( in_array( $req->username, $mailThrottledLimited, true ) ) {
579 return Status::newGood( 'throttled-mailpassword' );
582 $value = in_array( $req->username, $ignored, true )
583 ? 'ignored'
584 : 'okie dokie';
586 return in_array( $req->username, $allowed, true )
587 ? Status::newGood( $value )
588 : Status::newFatal( 'rejected by test mock' );
589 } );
590 // changeAuthenticationData is executed in the deferred update class
591 // SendPasswordResetEmailUpdate
592 $authManager->expects( $this->exactly( $numUsersToAuth ) )
593 ->method( 'changeAuthenticationData' );
595 /** @var AuthManager $authManager */
596 return $authManager;
600 * @return User[]
602 private function makeUsers() {
603 $getGoodUserCb = function ( int $num ) {
604 $user = $this->createMock( User::class );
605 $user->method( 'getName' )->willReturn( "User$num" );
606 $user->method( 'getId' )->willReturn( $num );
607 $user->method( 'isRegistered' )->willReturn( true );
608 $user->method( 'getEmail' )->willReturn( self::VALID_EMAIL );
609 return $user;
611 $user1 = $getGoodUserCb( 1 );
612 $user2 = $getGoodUserCb( 2 );
613 $user3 = $getGoodUserCb( 3 );
614 $user4 = $getGoodUserCb( 4 );
616 $badUser = $this->createMock( User::class );
617 $badUser->method( 'getName' )->willReturn( 'BadUser' );
618 $badUser->method( 'getId' )->willReturn( 5 );
619 $badUser->method( 'isRegistered' )->willReturn( true );
620 $badUser->method( 'getEmail' )->willReturn( '' );
622 $nonexistUser = $this->createMock( User::class );
623 $nonexistUser->method( 'getName' )->willReturn( 'Nonexistent user' );
624 $nonexistUser->method( 'getId' )->willReturn( 0 );
625 $nonexistUser->method( 'isRegistered' )->willReturn( false );
626 $nonexistUser->method( 'getEmail' )->willReturn( '' );
628 return [
629 'User1' => $user1,
630 'User2' => $user2,
631 'User3' => $user3,
632 'User4' => $user4,
633 'BadUser' => $badUser,
634 'Nonexistent user' => $nonexistUser,