351ef540bd90df6dfaae38c9a6e6906feaff080b
[lhc/web/wiklou.git] / tests / phpunit / includes / user / PasswordResetTest.php
1 <?php
2
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;
12
13 /**
14 * @covers PasswordReset
15 * @group Database
16 */
17 class PasswordResetTest extends MediaWikiTestCase {
18 const VALID_IP = '1.2.3.4';
19 const VALID_EMAIL = 'foo@bar.baz';
20
21 /**
22 * @dataProvider provideIsAllowed
23 */
24 public function testIsAllowed( $passwordResetRoutes, $enableEmail,
25 $allowsAuthenticationDataChange, $canEditPrivate, $block, $globalBlock, $isAllowed
26 ) {
27 $config = $this->makeConfig( $enableEmail, $passwordResetRoutes, false );
28
29 $authManager = $this->getMockBuilder( AuthManager::class )->disableOriginalConstructor()
30 ->getMock();
31 $authManager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' )
32 ->willReturn( $allowsAuthenticationDataChange ? Status::newGood() : Status::newFatal( 'foo' ) );
33
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 );
38
39 $permissionManager = $this->getMockBuilder( PermissionManager::class )
40 ->disableOriginalConstructor()
41 ->getMock();
42 $permissionManager->method( 'userHasRight' )
43 ->with( $user, 'editmyprivateinfo' )
44 ->willReturn( $canEditPrivate );
45
46 $loadBalancer = $this->getMockBuilder( ILoadBalancer::class )->getMock();
47
48 $passwordReset = new PasswordReset(
49 $config,
50 $authManager,
51 $permissionManager,
52 $loadBalancer,
53 new NullLogger()
54 );
55
56 $this->assertSame( $isAllowed, $passwordReset->isAllowed( $user )->isGood() );
57 }
58
59 public function provideIsAllowed() {
60 return [
61 'no routes' => [
62 'passwordResetRoutes' => [],
63 'enableEmail' => true,
64 'allowsAuthenticationDataChange' => true,
65 'canEditPrivate' => true,
66 'block' => null,
67 'globalBlock' => null,
68 'isAllowed' => false,
69 ],
70 'email disabled' => [
71 'passwordResetRoutes' => [ 'username' => true ],
72 'enableEmail' => false,
73 'allowsAuthenticationDataChange' => true,
74 'canEditPrivate' => true,
75 'block' => null,
76 'globalBlock' => null,
77 'isAllowed' => false,
78 ],
79 'auth data change disabled' => [
80 'passwordResetRoutes' => [ 'username' => true ],
81 'enableEmail' => true,
82 'allowsAuthenticationDataChange' => false,
83 'canEditPrivate' => true,
84 'block' => null,
85 'globalBlock' => null,
86 'isAllowed' => false,
87 ],
88 'cannot edit private data' => [
89 'passwordResetRoutes' => [ 'username' => true ],
90 'enableEmail' => true,
91 'allowsAuthenticationDataChange' => true,
92 'canEditPrivate' => false,
93 'block' => null,
94 'globalBlock' => null,
95 'isAllowed' => false,
96 ],
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,
105 ],
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,
113 'isAllowed' => true,
114 ],
115 'using blocked proxy' => [
116 'passwordResetRoutes' => [ 'username' => true ],
117 'enableEmail' => true,
118 'allowsAuthenticationDataChange' => true,
119 'canEditPrivate' => true,
120 'block' => new SystemBlock(
121 [ 'systemBlock' => 'proxy' ]
122 ),
123 'globalBlock' => null,
124 'isAllowed' => false,
125 ],
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 'globalBlock' => new SystemBlock(
133 [ 'systemBlock' => 'global-block' ]
134 ),
135 'isAllowed' => true,
136 ],
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 ]
144 ),
145 'globalBlock' => null,
146 'isAllowed' => true,
147 ],
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,
156 ],
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 ] ),
165 new Block( [] ),
166 ]
167 ] ),
168 'globalBlock' => null,
169 'isAllowed' => true,
170 ],
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' ] ),
180 ]
181 ] ),
182 'globalBlock' => null,
183 'isAllowed' => false,
184 ],
185 'all OK' => [
186 'passwordResetRoutes' => [ 'username' => true ],
187 'enableEmail' => true,
188 'allowsAuthenticationDataChange' => true,
189 'canEditPrivate' => true,
190 'block' => null,
191 'globalBlock' => null,
192 'isAllowed' => true,
193 ],
194 ];
195 }
196
197 /**
198 * @expectedException \LogicException
199 */
200 public function testExecute_notAllowed() {
201 $user = $this->getMock( User::class );
202 /** @var User $user */
203
204 $passwordReset = $this->getMockBuilder( PasswordReset::class )
205 ->disableOriginalConstructor()
206 ->setMethods( [ 'isAllowed' ] )
207 ->getMock();
208 $passwordReset->expects( $this->any() )
209 ->method( 'isAllowed' )
210 ->with( $user )
211 ->willReturn( Status::newFatal( 'somestatuscode' ) );
212 /** @var PasswordReset $passwordReset */
213
214 $passwordReset->execute( $user );
215 }
216
217 /**
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
228 */
229 public function testExecute(
230 $expectedError,
231 ServiceOptions $config,
232 User $performingUser,
233 PermissionManager $permissionManager,
234 AuthManager $authManager,
235 $username = '',
236 $email = '',
237 array $usersWithEmail = []
238 ) {
239 // Unregister the hooks for proper unit testing
240 $this->mergeMwGlobalArrayValue( 'wgHooks', [
241 'User::mailPasswordInternal' => [],
242 'SpecialPasswordResetOnSubmit' => [],
243 ] );
244
245 $loadBalancer = $this->getMockBuilder( ILoadBalancer::class )
246 ->getMock();
247
248 $users = $this->makeUsers();
249
250 $lookupUser = function ( $username ) use ( $users ) {
251 return $users[ $username ] ?? false;
252 };
253
254 $passwordReset = $this->getMockBuilder( PasswordReset::class )
255 ->setMethods( [ 'getUsersByEmail', 'isAllowed', 'lookupUser' ] )
256 ->setConstructorArgs( [
257 $config,
258 $authManager,
259 $permissionManager,
260 $loadBalancer,
261 new NullLogger()
262 ] )
263 ->getMock();
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 );
270
271 /** @var PasswordReset $passwordReset */
272 $status = $passwordReset->execute( $performingUser, $username, $email );
273 $this->assertStatus( $status, $expectedError );
274 }
275
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 );
282
283 return [
284 'Invalid email' => [
285 'expectedError' => 'passwordreset-invalidemail',
286 'config' => $defaultConfig,
287 'performingUser' => $throttledUser,
288 'permissionManager' => $permissionManager,
289 'authManager' => $this->makeAuthManager(),
290 'username' => '',
291 'email' => '[invalid email]',
292 'usersWithEmail' => [],
293 ],
294 'No username, no email' => [
295 'expectedError' => 'passwordreset-nodata',
296 'config' => $defaultConfig,
297 'performingUser' => $throttledUser,
298 'permissionManager' => $permissionManager,
299 'authManager' => $this->makeAuthManager(),
300 'username' => '',
301 'email' => '',
302 'usersWithEmail' => [],
303 ],
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(),
310 'username' => '',
311 'email' => self::VALID_EMAIL,
312 'usersWithEmail' => [],
313 ],
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',
321 'email' => '',
322 'usersWithEmail' => [],
323 ],
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' => [],
333 ],
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',
341 'email' => '',
342 'usersWithEmail' => [],
343 ],
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' => [],
353 ],
354 'Throttled' => [
355 'expectedError' => 'actionthrottledtext',
356 'config' => $defaultConfig,
357 'performingUser' => $throttledUser,
358 'permissionManager' => $permissionManager,
359 'authManager' => $this->makeAuthManager(),
360 'username' => 'User1',
361 'email' => '',
362 'usersWithEmail' => [],
363 ],
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',
371 'email' => '',
372 'usersWithEmail' => [],
373 ],
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(),
380 'username' => '',
381 'email' => 'some@not.found.email',
382 'usersWithEmail' => [],
383 ],
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',
391 'email' => '',
392 'usersWithEmail' => [],
393 ],
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' => [],
403 ],
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',
411 'email' => '',
412 'usersWithEmail' => [],
413 ],
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',
421 'email' => '',
422 'usersWithEmail' => [],
423 ],
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' ] ),
430 'username' => '',
431 'email' => self::VALID_EMAIL,
432 'usersWithEmail' => [ 'User1', 'User2' ],
433 ],
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',
441 'email' => '',
442 'usersWithEmail' => [],
443 ],
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' ] ),
450 'username' => '',
451 'email' => self::VALID_EMAIL,
452 'usersWithEmail' => [ 'User1', 'User2' ],
453 ],
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' ],
464 ],
465 'Reset one user via email' => [
466 'expectedError' => false,
467 'config' => $defaultConfig,
468 'performingUser' => $performingUser,
469 'permissionManager' => $permissionManager,
470 'authManager' => $this->makeAuthManager( [ 'User1' ], 1 ),
471 'username' => '',
472 'email' => self::VALID_EMAIL,
473 'usersWithEmail' => [ 'User1' ],
474 ],
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 ),
481 'username' => '',
482 'email' => self::VALID_EMAIL,
483 'usersWithEmail' => [ 'User1', 'User2' ],
484 ],
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' ],
494 ],
495 ];
496 }
497
498 private function assertStatus( StatusValue $status, $error = false ) {
499 if ( $error === false ) {
500 $this->assertTrue( $status->isGood(), 'Expected status to be good' );
501 } else {
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();
508 }
509 $this->assertSame( $error, $message );
510 }
511 }
512 }
513
514 private function makeConfig( $enableEmail, array $passwordResetRoutes, $emailForResets ) {
515 $hash = [
516 'AllowRequiringEmailForResets' => $emailForResets,
517 'EnableEmail' => $enableEmail,
518 'PasswordResetRoutes' => $passwordResetRoutes,
519 ];
520
521 return new ServiceOptions( PasswordReset::CONSTRUCTOR_OPTIONS, $hash );
522 }
523
524 /**
525 * @param string|null $ip
526 * @param bool $pingLimited
527 * @return User
528 */
529 private function makePerformingUser( $ip, $pingLimited ) : User {
530 $request = $this->getMockBuilder( WebRequest::class )
531 ->getMock();
532 $request->method( 'getIP' )
533 ->willReturn( $ip );
534 /** @var WebRequest $request */
535
536 $user = $this->getMockBuilder( User::class )
537 ->setMethods( [ 'getName', 'pingLimiter', 'getRequest' ] )
538 ->getMock();
539
540 $user->method( 'getName' )
541 ->willReturn( 'SomeUser' );
542 $user->method( 'pingLimiter' )
543 ->with( 'mailpassword' )
544 ->willReturn( $pingLimited );
545 $user->method( 'getRequest' )
546 ->willReturn( $request );
547
548 /** @var User $user */
549 return $user;
550 }
551
552 private function makePermissionManager( User $performingUser, $isAllowed ) : PermissionManager {
553 $permissionManager = $this->getMockBuilder( PermissionManager::class )
554 ->disableOriginalConstructor()
555 ->getMock();
556 $permissionManager->method( 'userHasRight' )
557 ->with( $performingUser, 'editmyprivateinfo' )
558 ->willReturn( $isAllowed );
559
560 /** @var PermissionManager $permissionManager */
561 return $permissionManager;
562 }
563
564 /**
565 * @param string[] $allowed
566 * @param int $numUsersToAuth
567 * @param string[] $ignored
568 * @return AuthManager
569 */
570 private function makeAuthManager(
571 array $allowed = [],
572 $numUsersToAuth = 0,
573 array $ignored = []
574 ) : AuthManager {
575 $authManager = $this->getMockBuilder( AuthManager::class )
576 ->disableOriginalConstructor()
577 ->getMock();
578 $authManager->method( 'allowsAuthenticationDataChange' )
579 ->willReturnCallback(
580 function ( TemporaryPasswordAuthenticationRequest $req ) use ( $allowed, $ignored ) {
581 $value = in_array( $req->username, $ignored, true )
582 ? 'ignored'
583 : 'okie dokie';
584 return in_array( $req->username, $allowed, true )
585 ? Status::newGood( $value )
586 : Status::newFatal( 'rejected by test mock' );
587 } );
588 // changeAuthenticationData is executed in the deferred update class
589 // SendPasswordResetEmailUpdate
590 $authManager->expects( $this->exactly( $numUsersToAuth ) )
591 ->method( 'changeAuthenticationData' );
592
593 /** @var AuthManager $authManager */
594 return $authManager;
595 }
596
597 /**
598 * @return User[]
599 */
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 );
609
610 $user1->method( 'getBoolOption' )
611 ->with( 'requireemail' )
612 ->willReturn( true );
613
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 );
618
619 return [
620 'User1' => $user1,
621 'User2' => $user2,
622 'BadUser' => $badUser,
623 ];
624 }
625 }