This is non-trivial because: - "blockedemailuser" is not a real message, so it can't be passed to StatusValue. For now, use BlockErrorFormatter to obtain a suitable message. In the future, we should simply rely on Authority/PermissionManager to set a message for us, but that's left for another change. - The hooks used here return an error in the format accepted by ErrorPageError. There's no "official" way to do this with StatusValue. This patch uses StatusValue::value as a temporary workaround, but these hooks should be replaced with a new hook that uses StatusValue for errors. As an aside, note that ApiEmailUser passes the return value of getPermissionsError to dieWithError(), which doesn't work in the cases above. This is a pre-existing issue that will be fixed in another patch. Bug: T265541 Change-Id: I84cc521b24adef6406e0378a293438d5cd7db02a
494 lines
16 KiB
PHP
494 lines
16 KiB
PHP
<?php
|
|
|
|
namespace MediaWiki\Tests\Unit\Mail;
|
|
|
|
use CentralIdLookup;
|
|
use Generator;
|
|
use MediaWiki\Config\ServiceOptions;
|
|
use MediaWiki\Language\RawMessage;
|
|
use MediaWiki\Mail\EmailUser;
|
|
use MediaWiki\Mail\IEmailer;
|
|
use MediaWiki\MainConfigNames;
|
|
use MediaWiki\Permissions\PermissionManager;
|
|
use MediaWiki\User\UserFactory;
|
|
use MediaWiki\User\UserOptionsLookup;
|
|
use MediaWikiUnitTestCase;
|
|
use Message;
|
|
use MessageLocalizer;
|
|
use RuntimeException;
|
|
use StatusValue;
|
|
use User;
|
|
|
|
/**
|
|
* @coversDefaultClass \MediaWiki\Mail\EmailUser
|
|
* @covers ::__construct
|
|
*/
|
|
class EmailUserTest extends MediaWikiUnitTestCase {
|
|
private function getEmailUser(
|
|
UserOptionsLookup $userOptionsLookup = null,
|
|
CentralIdLookup $centralIdLookup = null,
|
|
UserFactory $userFactory = null,
|
|
PermissionManager $permissionManager = null,
|
|
array $configOverrides = [],
|
|
array $hooks = [],
|
|
IEmailer $emailer = null
|
|
): EmailUser {
|
|
$options = new ServiceOptions(
|
|
EmailUser::CONSTRUCTOR_OPTIONS,
|
|
$configOverrides + [
|
|
MainConfigNames::EnableEmail => true,
|
|
MainConfigNames::EnableUserEmail => true,
|
|
MainConfigNames::EnableSpecialMute => true,
|
|
MainConfigNames::PasswordSender => 'foo@bar.baz',
|
|
MainConfigNames::UserEmailUseReplyTo => true,
|
|
]
|
|
);
|
|
|
|
return new EmailUser(
|
|
$options,
|
|
$this->createHookContainer( $hooks ),
|
|
$userOptionsLookup ?? $this->createMock( UserOptionsLookup::class ),
|
|
$centralIdLookup ?? $this->createMock( CentralIdLookup::class ),
|
|
$permissionManager ?? $this->createMock( PermissionManager::class ),
|
|
$userFactory ?? $this->createMock( UserFactory::class ),
|
|
$emailer ?? $this->createMock( IEmailer::class )
|
|
);
|
|
}
|
|
|
|
private function getValidTargetUserFactory( string $targetName ): UserFactory {
|
|
$validTarget = $this->createMock( User::class );
|
|
$validTarget->method( 'getId' )->willReturn( 1 );
|
|
$validTarget->method( 'isEmailConfirmed' )->willReturn( true );
|
|
$validTarget->method( 'canReceiveEmail' )->willReturn( true );
|
|
$validTargetUserFactory = $this->createMock( UserFactory::class );
|
|
$validTargetUserFactory->expects( $this->atLeastOnce() )
|
|
->method( 'newFromName' )
|
|
->with( $targetName )
|
|
->willReturn( $validTarget );
|
|
return $validTargetUserFactory;
|
|
}
|
|
|
|
/**
|
|
* Returns a valid MessageLocalizer mock. We don't care about MessageLocalizer at all, but since the return value
|
|
* of ::msg() is not typehinted, we're forced to specify a mocked Message to return so that chained method calls
|
|
* won't break.
|
|
* @return MessageLocalizer
|
|
*/
|
|
private function getMockMessageLocalizer(): MessageLocalizer {
|
|
$messageLocalizer = $this->createMock( MessageLocalizer::class );
|
|
$messageLocalizer->method( 'msg' )->willReturn( $this->createMock( Message::class ) );
|
|
return $messageLocalizer;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $target
|
|
* @param User $sender
|
|
* @param StatusValue|null $expected StatusValue object to compare for errors, or null to indicate that we're
|
|
* expecting a good StatusValue with a User object as value.
|
|
* @param UserFactory|null $userFactory
|
|
* @covers ::getTarget
|
|
* @dataProvider provideGetTarget
|
|
*/
|
|
public function testGetTarget( $target, User $sender, ?StatusValue $expected, UserFactory $userFactory = null ) {
|
|
$emailUser = $this->getEmailUser( null, null, $userFactory );
|
|
$actualStatus = $emailUser->getTarget( $target, $sender );
|
|
if ( $expected === null ) {
|
|
$this->assertStatusGood( $actualStatus );
|
|
$this->assertInstanceOf( User::class, $actualStatus->getValue() );
|
|
} else {
|
|
$this->assertEquals( $expected, $actualStatus );
|
|
}
|
|
}
|
|
|
|
public function provideGetTarget(): Generator {
|
|
$targetName = 'John Doe';
|
|
$noopSender = $this->createMock( User::class );
|
|
|
|
$invalidUsername = '123<>|[]';
|
|
$invalidUsernameUserFactory = $this->createMock( UserFactory::class );
|
|
$invalidUsernameUserFactory->expects( $this->atLeastOnce() )
|
|
->method( 'newFromName' )
|
|
->with( $invalidUsername )
|
|
->willReturn( null );
|
|
yield 'Invalid username' => [
|
|
$invalidUsername,
|
|
$noopSender,
|
|
StatusValue::newFatal( 'emailnotarget' ),
|
|
$invalidUsernameUserFactory
|
|
];
|
|
|
|
$noEmailTarget = $this->createMock( User::class );
|
|
$noEmailTarget->method( 'getId' )->willReturn( 1 );
|
|
$noEmailTarget->method( 'isEmailConfirmed' )->willReturn( false );
|
|
$noEmailTargetUserFactory = $this->createMock( UserFactory::class );
|
|
$noEmailTargetUserFactory->expects( $this->atLeastOnce() )
|
|
->method( 'newFromName' )
|
|
->with( $targetName )
|
|
->willReturn( $noEmailTarget );
|
|
yield 'Invalid target' => [
|
|
$targetName,
|
|
$noopSender,
|
|
StatusValue::newFatal( 'noemailtext' ),
|
|
$noEmailTargetUserFactory
|
|
];
|
|
|
|
$validTargetUserFactory = $this->getValidTargetUserFactory( $targetName );
|
|
yield 'Valid target' => [ $targetName, $noopSender, null, $validTargetUserFactory ];
|
|
}
|
|
|
|
/**
|
|
* @covers ::validateTarget
|
|
* @dataProvider provideValidateTarget
|
|
*/
|
|
public function testValidateTarget(
|
|
User $target,
|
|
User $sender,
|
|
StatusValue $expected,
|
|
UserOptionsLookup $userOptionsLookup = null,
|
|
CentralIdLookup $centralIdLookup = null
|
|
) {
|
|
$emailUser = $this->getEmailUser( $userOptionsLookup, $centralIdLookup );
|
|
$this->assertEquals( $expected, $emailUser->validateTarget( $target, $sender ) );
|
|
}
|
|
|
|
public function provideValidateTarget(): Generator {
|
|
$noopUserMock = $this->createMock( User::class );
|
|
$validTarget = $this->createMock( User::class );
|
|
$validTarget->method( 'getId' )->willReturn( 1 );
|
|
$validTarget->method( 'isEmailConfirmed' )->willReturn( true );
|
|
$validTarget->method( 'canReceiveEmail' )->willReturn( true );
|
|
|
|
$anonTarget = $this->createMock( User::class );
|
|
$anonTarget->expects( $this->atLeastOnce() )->method( 'getId' )->willReturn( 0 );
|
|
yield 'Target has user ID 0' => [ $anonTarget, $noopUserMock, StatusValue::newFatal( 'emailnotarget' ) ];
|
|
|
|
$emailNotConfirmedTarget = $this->createMock( User::class );
|
|
$emailNotConfirmedTarget->method( 'getId' )->willReturn( 1 );
|
|
$emailNotConfirmedTarget->expects( $this->atLeastOnce() )->method( 'isEmailConfirmed' )->willReturn( false );
|
|
yield 'Target does not have confirmed email' => [
|
|
$emailNotConfirmedTarget,
|
|
$noopUserMock,
|
|
StatusValue::newFatal( 'noemailtext' )
|
|
];
|
|
|
|
$cannotReceiveEmailsTarget = $this->createMock( User::class );
|
|
$cannotReceiveEmailsTarget->method( 'getId' )->willReturn( 1 );
|
|
$cannotReceiveEmailsTarget->method( 'isEmailConfirmed' )->willReturn( true );
|
|
$cannotReceiveEmailsTarget->expects( $this->atLeastOnce() )->method( 'canReceiveEmail' )->willReturn( false );
|
|
yield 'Target cannot receive emails' => [
|
|
$cannotReceiveEmailsTarget,
|
|
$noopUserMock,
|
|
StatusValue::newFatal( 'nowikiemailtext' )
|
|
];
|
|
|
|
$newbieSender = $this->createMock( User::class );
|
|
$newbieSender->expects( $this->atLeastOnce() )->method( 'isNewbie' )->willReturn( true );
|
|
$noNewbieEmailsOptionsLookup = $this->createMock( UserOptionsLookup::class );
|
|
$noNewbieEmailsOptionsLookup->expects( $this->atLeastOnce() )
|
|
->method( 'getOption' )
|
|
->with( $validTarget, 'email-allow-new-users' )
|
|
->willReturn( false );
|
|
yield 'Target does not allow emails from newbie and sender is newbie' => [
|
|
$validTarget,
|
|
$newbieSender,
|
|
StatusValue::newFatal( 'nowikiemailtext' ),
|
|
$noNewbieEmailsOptionsLookup
|
|
];
|
|
|
|
$senderCentralID = 42;
|
|
$muteListOptionsLookup = $this->createMock( UserOptionsLookup::class );
|
|
$muteListOptionsLookup->expects( $this->atLeast( 2 ) )
|
|
->method( 'getOption' )
|
|
->willReturnCallback( static function ( $_, $option ) use ( $senderCentralID ) {
|
|
return $option === 'email-blacklist' ? (string)$senderCentralID : true;
|
|
} );
|
|
$centralIdLookup = $this->createMock( CentralIdLookup::class );
|
|
$centralIdLookup->expects( $this->atLeastOnce() )
|
|
->method( 'centralIdFromLocalUser' )
|
|
->with( $noopUserMock )
|
|
->willReturn( $senderCentralID );
|
|
yield 'Target muted the sender' => [
|
|
$validTarget,
|
|
$noopUserMock,
|
|
StatusValue::newFatal( 'nowikiemailtext' ),
|
|
$muteListOptionsLookup,
|
|
$centralIdLookup
|
|
];
|
|
|
|
yield 'Valid' => [ $validTarget, $noopUserMock, StatusValue::newGood() ];
|
|
}
|
|
|
|
/**
|
|
* @covers ::getPermissionsError
|
|
* @dataProvider providePermissionsError
|
|
*/
|
|
public function testGetPermissionsError(
|
|
User $user,
|
|
StatusValue $expected,
|
|
PermissionManager $permissionManager = null,
|
|
array $configOverrides = [],
|
|
array $hooks = []
|
|
) {
|
|
$emailUser = $this->getEmailUser( null, null, null, $permissionManager, $configOverrides, $hooks );
|
|
$this->assertEquals( $expected, $emailUser->getPermissionsError( $user, 'some-token' ) );
|
|
}
|
|
|
|
public function providePermissionsError(): Generator {
|
|
$validSender = $this->createMock( User::class );
|
|
$validSender->method( 'isEmailConfirmed' )->willReturn( true );
|
|
|
|
yield 'Emails disabled' => [
|
|
$validSender,
|
|
StatusValue::newFatal( 'usermaildisabled' ),
|
|
null,
|
|
[ MainConfigNames::EnableEmail => false ]
|
|
];
|
|
|
|
yield 'User emails disabled' => [
|
|
$validSender,
|
|
StatusValue::newFatal( 'usermaildisabled' ),
|
|
null,
|
|
[ MainConfigNames::EnableUserEmail => false ]
|
|
];
|
|
|
|
$noEmailSender = $this->createMock( User::class );
|
|
$noEmailSender->expects( $this->atLeastOnce() )->method( 'isEmailConfirmed' )->willReturn( false );
|
|
yield 'Sender does not have an email' => [ $noEmailSender, StatusValue::newFatal( 'mailnologin' ) ];
|
|
|
|
$notAllowedPermManager = $this->createMock( PermissionManager::class );
|
|
$notAllowedPermManager->expects( $this->atLeastOnce() )
|
|
->method( 'userHasRight' )
|
|
->with( $validSender, 'sendemail' )
|
|
->willReturn( false );
|
|
yield 'Sender is not allowed to send emails' => [
|
|
$validSender,
|
|
StatusValue::newFatal( 'badaccess' ),
|
|
$notAllowedPermManager
|
|
];
|
|
|
|
$allowedPermManager = $this->createMock( PermissionManager::class );
|
|
$allowedPermManager->method( 'userHasRight' )
|
|
->with( $this->anything(), 'sendemail' )
|
|
->willReturn( true );
|
|
|
|
$blockedSender = $this->createMock( User::class );
|
|
$blockedSender->method( 'isEmailConfirmed' )->willReturn( true );
|
|
$blockedSender->expects( $this->atLeastOnce() )->method( 'isBlockedFromEmailuser' )->willReturn( true );
|
|
yield 'Sender is blocked from emailing users' => [
|
|
$blockedSender,
|
|
StatusValue::newFatal( new RawMessage( 'You shall not send' ) ),
|
|
$allowedPermManager
|
|
];
|
|
|
|
$ratelimitedSender = $this->createMock( User::class );
|
|
$ratelimitedSender->method( 'isEmailConfirmed' )->willReturn( true );
|
|
$ratelimitedSender->expects( $this->atLeastOnce() )
|
|
->method( 'pingLimiter' )
|
|
->with( 'sendemail' )
|
|
->willReturn( true );
|
|
yield 'Sender is rate-limited' => [
|
|
$ratelimitedSender,
|
|
StatusValue::newFatal( 'actionthrottledtext' ),
|
|
$allowedPermManager
|
|
];
|
|
|
|
$userCanSendEmailError = [ 'first-hook-error', 'first-hook-error-text', [] ];
|
|
$userCanSendEmailHooks = [
|
|
'UserCanSendEmail' => static function ( $user, &$err ) use ( $userCanSendEmailError ) {
|
|
$err = $userCanSendEmailError;
|
|
}
|
|
];
|
|
$expectedStatusFirstHook = StatusValue::newFatal( $userCanSendEmailError[1], ...$userCanSendEmailError[2] );
|
|
$expectedStatusFirstHook->value = $userCanSendEmailError[0];
|
|
yield 'UserCanSendEmail hook error' => [
|
|
$validSender,
|
|
$expectedStatusFirstHook,
|
|
$allowedPermManager,
|
|
[],
|
|
$userCanSendEmailHooks
|
|
];
|
|
|
|
$emailUserPermissionsErrorsError = [ 'second-hook-error', 'second-hook-error-text', [] ];
|
|
$emailUserPermissionsErrorsHooks = [
|
|
'EmailUserPermissionsErrors' =>
|
|
static function ( $user, $token, &$err ) use ( $emailUserPermissionsErrorsError ) {
|
|
$err = $emailUserPermissionsErrorsError;
|
|
}
|
|
];
|
|
$expectedStatusSecondHook = StatusValue::newFatal(
|
|
$emailUserPermissionsErrorsError[1],
|
|
...$emailUserPermissionsErrorsError[2]
|
|
);
|
|
$expectedStatusSecondHook->value = $emailUserPermissionsErrorsError[0];
|
|
yield 'EmailUserPermissionsErrors hook error' => [
|
|
$validSender,
|
|
$expectedStatusSecondHook,
|
|
$allowedPermManager,
|
|
[],
|
|
$emailUserPermissionsErrorsHooks
|
|
];
|
|
|
|
yield 'Successful' => [ $validSender, StatusValue::newGood(), $allowedPermManager ];
|
|
}
|
|
|
|
/**
|
|
* @covers ::submit
|
|
* @dataProvider provideSubmit
|
|
*/
|
|
public function testSubmit(
|
|
string $targetName,
|
|
User $sender,
|
|
$expected,
|
|
UserFactory $userFactory = null,
|
|
array $hooks = [],
|
|
IEmailer $emailer = null
|
|
) {
|
|
$emailUser = $this->getEmailUser( null, null, $userFactory, null, [], $hooks, $emailer );
|
|
$res = $emailUser->submit(
|
|
$targetName,
|
|
'subject',
|
|
'text',
|
|
false,
|
|
$sender,
|
|
$this->getMockMessageLocalizer()
|
|
);
|
|
if ( $expected instanceof StatusValue ) {
|
|
$this->assertEquals( $expected, $res );
|
|
} else {
|
|
$this->assertSame( $expected, $res );
|
|
}
|
|
}
|
|
|
|
public function provideSubmit(): Generator {
|
|
$validSender = $this->createMock( User::class );
|
|
$validTarget = 'John Doe';
|
|
$validTargetUserFactory = $this->getValidTargetUserFactory( $validTarget );
|
|
|
|
yield 'Invalid target' => [ '', $validSender, StatusValue::newFatal( 'emailnotarget' ) ];
|
|
|
|
$hookStatusError = StatusValue::newFatal( 'some-hook-error' );
|
|
$emailUserHookUsingStatusHooks = [
|
|
'EmailUser' => static function ( $a, $b, $c, $d, &$err ) use ( $hookStatusError ) {
|
|
$err = $hookStatusError;
|
|
return false;
|
|
}
|
|
];
|
|
yield 'EmailUserHook error as a Status' => [
|
|
$validTarget,
|
|
$validSender,
|
|
$hookStatusError,
|
|
$validTargetUserFactory,
|
|
$emailUserHookUsingStatusHooks
|
|
];
|
|
|
|
$emailUserHookUsingBooleanFalseHooks = [
|
|
'EmailUser' => static function ( $a, $b, $c, $d, &$err ) {
|
|
$err = false;
|
|
return false;
|
|
}
|
|
];
|
|
yield 'EmailUserHook error as boolean false' => [
|
|
$validTarget,
|
|
$validSender,
|
|
StatusValue::newFatal( 'hookaborted' ),
|
|
$validTargetUserFactory,
|
|
$emailUserHookUsingBooleanFalseHooks
|
|
];
|
|
|
|
$emailUserHookUsingBooleanTrueHooks = [
|
|
'EmailUser' => static function ( $a, $b, $c, $d, &$err ) {
|
|
$err = true;
|
|
return false;
|
|
}
|
|
];
|
|
yield 'EmailUserHook error as boolean true' => [
|
|
$validTarget,
|
|
$validSender,
|
|
StatusValue::newGood(),
|
|
$validTargetUserFactory,
|
|
$emailUserHookUsingBooleanTrueHooks
|
|
];
|
|
|
|
$hookError = 'a-hook-error';
|
|
$emailUserHookUsingArrayHooks = [
|
|
'EmailUser' => static function ( $a, $b, $c, $d, &$err ) use ( $hookError ) {
|
|
$err = [ $hookError ];
|
|
return false;
|
|
}
|
|
];
|
|
yield 'EmailUserHook error as array' => [
|
|
$validTarget,
|
|
$validSender,
|
|
StatusValue::newFatal( $hookError ),
|
|
$validTargetUserFactory,
|
|
$emailUserHookUsingArrayHooks
|
|
];
|
|
|
|
$hookErrorMsg = new RawMessage( 'Some hook error' );
|
|
$emailUserHookUsingMessageHooks = [
|
|
'EmailUser' => static function ( $a, $b, $c, $d, &$err ) use ( $hookErrorMsg ) {
|
|
$err = $hookErrorMsg;
|
|
return false;
|
|
}
|
|
];
|
|
yield 'EmailUserHook error as MessageSpecifier' => [
|
|
$validTarget,
|
|
$validSender,
|
|
StatusValue::newFatal( $hookErrorMsg ),
|
|
$validTargetUserFactory,
|
|
$emailUserHookUsingMessageHooks
|
|
];
|
|
|
|
$emailerErrorStatus = StatusValue::newFatal( 'emailer-error' );
|
|
$errorEmailer = $this->createMock( IEmailer::class );
|
|
$errorEmailer->expects( $this->atLeastOnce() )->method( 'send' )->willReturn( $emailerErrorStatus );
|
|
yield 'Error in the Emailer' => [
|
|
$validTarget,
|
|
$validSender,
|
|
$emailerErrorStatus,
|
|
$validTargetUserFactory,
|
|
[],
|
|
$errorEmailer
|
|
];
|
|
|
|
$emailerSuccessStatus = StatusValue::newGood( 42 );
|
|
$successEmailer = $this->createMock( IEmailer::class );
|
|
$successEmailer->expects( $this->atLeastOnce() )->method( 'send' )->willReturn( $emailerSuccessStatus );
|
|
yield 'Successful' => [
|
|
$validTarget,
|
|
$validSender,
|
|
$emailerSuccessStatus,
|
|
$validTargetUserFactory,
|
|
[],
|
|
$successEmailer
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @covers ::submit
|
|
*/
|
|
public function testSubmit__rateLimited() {
|
|
$sender = $this->createMock( User::class );
|
|
$sender->method( 'pingLimiter' )->with( 'sendemail' )->willReturn( true );
|
|
$validTarget = $this->createMock( User::class );
|
|
$validTarget->method( 'getId' )->willReturn( 1 );
|
|
$validTarget->method( 'isEmailConfirmed' )->willReturn( true );
|
|
$validTarget->method( 'canReceiveEmail' )->willReturn( true );
|
|
$validUserFactory = $this->createMock( UserFactory::class );
|
|
$validUserFactory->method( 'newFromName' )->willReturn( $validTarget );
|
|
|
|
$this->expectException( RuntimeException::class );
|
|
$this->expectExceptionMessage( 'You are throttled' );
|
|
$res = $this->getEmailUser( null, null, $validUserFactory )->submit(
|
|
'Some target',
|
|
'subject',
|
|
'text',
|
|
false,
|
|
$sender,
|
|
$this->getMockMessageLocalizer()
|
|
);
|
|
// This assertion should not be reached if the test passes, but it can be helpful to determine why
|
|
// the test is failing.
|
|
$this->assertStatusGood( $res );
|
|
}
|
|
}
|