diff options
Diffstat (limited to 'www/wiki/tests/phpunit/includes/auth')
30 files changed, 9649 insertions, 0 deletions
diff --git a/www/wiki/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php new file mode 100644 index 00000000..b271b701 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php @@ -0,0 +1,30 @@ +<?php + +namespace MediaWiki\Auth; + +use Wikimedia\TestingAccessWrapper; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\AbstractAuthenticationProvider + */ +class AbstractAuthenticationProviderTest extends \MediaWikiTestCase { + public function testAbstractAuthenticationProvider() { + $provider = $this->getMockForAbstractClass( AbstractAuthenticationProvider::class ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + + $obj = $this->getMockForAbstractClass( \Psr\Log\LoggerInterface::class ); + $provider->setLogger( $obj ); + $this->assertSame( $obj, $providerPriv->logger, 'setLogger' ); + + $obj = AuthManager::singleton(); + $provider->setManager( $obj ); + $this->assertSame( $obj, $providerPriv->manager, 'setManager' ); + + $obj = $this->getMockForAbstractClass( \Config::class ); + $provider->setConfig( $obj ); + $this->assertSame( $obj, $providerPriv->config, 'setConfig' ); + + $this->assertType( 'string', $provider->getUniqueId(), 'getUniqueId' ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php new file mode 100644 index 00000000..cb015df6 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php @@ -0,0 +1,228 @@ +<?php + +namespace MediaWiki\Auth; + +use MediaWiki\MediaWikiServices; +use Wikimedia\TestingAccessWrapper; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\AbstractPasswordPrimaryAuthenticationProvider + */ +class AbstractPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestCase { + public function testConstructor() { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + $this->assertTrue( $providerPriv->authoritative ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class, + [ [ 'authoritative' => false ] ] + ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + $this->assertFalse( $providerPriv->authoritative ); + } + + public function testGetPasswordFactory() { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( MediaWikiServices::getInstance()->getMainConfig() ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + + $obj = $providerPriv->getPasswordFactory(); + $this->assertInstanceOf( \PasswordFactory::class, $obj ); + $this->assertSame( $obj, $providerPriv->getPasswordFactory() ); + } + + public function testGetPassword() { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( MediaWikiServices::getInstance()->getMainConfig() ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + + $obj = $providerPriv->getPassword( null ); + $this->assertInstanceOf( \Password::class, $obj ); + + $obj = $providerPriv->getPassword( 'invalid' ); + $this->assertInstanceOf( \Password::class, $obj ); + } + + public function testGetNewPasswordExpiry() { + $config = new \HashConfig; + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( new \MultiConfig( [ + $config, + MediaWikiServices::getInstance()->getMainConfig() + ] ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + + $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'ResetPasswordExpiration' => [] ] ); + + $config->set( 'PasswordExpirationDays', 0 ); + $this->assertNull( $providerPriv->getNewPasswordExpiry( 'UTSysop' ) ); + + $config->set( 'PasswordExpirationDays', 5 ); + $this->assertEquals( + time() + 5 * 86400, + wfTimestamp( TS_UNIX, $providerPriv->getNewPasswordExpiry( 'UTSysop' ) ), + '', + 2 /* Fuzz */ + ); + + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'ResetPasswordExpiration' => [ function ( $user, &$expires ) { + $this->assertSame( 'UTSysop', $user->getName() ); + $expires = '30001231235959'; + } ] + ] ); + $this->assertEquals( '30001231235959', $providerPriv->getNewPasswordExpiry( 'UTSysop' ) ); + } + + public function testCheckPasswordValidity() { + $uppCalled = 0; + $uppStatus = \Status::newGood(); + $this->setMwGlobals( [ + 'wgPasswordPolicy' => [ + 'policies' => [ + 'default' => [ + 'Check' => true, + ], + ], + 'checks' => [ + 'Check' => function () use ( &$uppCalled, &$uppStatus ) { + $uppCalled++; + return $uppStatus; + }, + ], + ] + ] ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( MediaWikiServices::getInstance()->getMainConfig() ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + + $this->assertEquals( $uppStatus, $providerPriv->checkPasswordValidity( 'foo', 'bar' ) ); + + $uppStatus->fatal( 'arbitrary-warning' ); + $this->assertEquals( $uppStatus, $providerPriv->checkPasswordValidity( 'foo', 'bar' ) ); + } + + public function testSetPasswordResetFlag() { + $config = new \HashConfig( [ + 'InvalidPasswordReset' => true, + ] ); + + $manager = new AuthManager( + new \FauxRequest(), + MediaWikiServices::getInstance()->getMainConfig() + ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( $config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $manager ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + + $manager->removeAuthenticationSessionData( null ); + $status = \Status::newGood(); + $providerPriv->setPasswordResetFlag( 'Foo', $status ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + + $manager->removeAuthenticationSessionData( null ); + $status = \Status::newGood(); + $status->error( 'testing' ); + $providerPriv->setPasswordResetFlag( 'Foo', $status ); + $ret = $manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() ); + $this->assertFalse( $ret->hard ); + + $config->set( 'InvalidPasswordReset', false ); + $manager->removeAuthenticationSessionData( null ); + $providerPriv->setPasswordResetFlag( 'Foo', $status ); + $ret = $manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNull( $ret ); + } + + public function testFailResponse() { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class, + [ [ 'authoritative' => false ] ] + ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + + $req = new PasswordAuthenticationRequest; + + $ret = $providerPriv->failResponse( $req ); + $this->assertSame( AuthenticationResponse::ABSTAIN, $ret->status ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class, + [ [ 'authoritative' => true ] ] + ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + + $req->password = ''; + $ret = $providerPriv->failResponse( $req ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpasswordempty', $ret->message->getKey() ); + + $req->password = 'X'; + $ret = $providerPriv->failResponse( $req ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpassword', $ret->message->getKey() ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + */ + public function testGetAuthenticationRequests( $action, $response ) { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [ new PasswordAuthenticationRequest() ] ], + [ AuthManager::ACTION_CREATE, [ new PasswordAuthenticationRequest() ] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [ new PasswordAuthenticationRequest() ] ], + [ AuthManager::ACTION_REMOVE, [ new PasswordAuthenticationRequest() ] ], + ]; + } + + public function testProviderRevokeAccessForUser() { + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_REMOVE; + $req->username = 'foo'; + $req->password = null; + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->expects( $this->once() ) + ->method( 'providerChangeAuthenticationData' ) + ->with( $this->equalTo( $req ) ); + + $provider->providerRevokeAccessForUser( 'foo' ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php new file mode 100644 index 00000000..96384518 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php @@ -0,0 +1,45 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\AbstractPreAuthenticationProvider + */ +class AbstractPreAuthenticationProviderTest extends \MediaWikiTestCase { + public function testAbstractPreAuthenticationProvider() { + $user = \User::newFromName( 'UTSysop' ); + + $provider = $this->getMockForAbstractClass( AbstractPreAuthenticationProvider::class ); + + $this->assertEquals( + [], + $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAuthentication( [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, false ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountLink( $user ) + ); + + $res = AuthenticationResponse::newPass(); + $provider->postAuthentication( $user, $res ); + $provider->postAccountCreation( $user, $user, $res ); + $provider->postAccountLink( $user, $res ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php new file mode 100644 index 00000000..8d84f4ca --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php @@ -0,0 +1,174 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\AbstractPrimaryAuthenticationProvider + */ +class AbstractPrimaryAuthenticationProviderTest extends \MediaWikiTestCase { + public function testAbstractPrimaryAuthenticationProvider() { + $user = \User::newFromName( 'UTSysop' ); + + $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + + try { + $provider->continuePrimaryAuthentication( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + try { + $provider->continuePrimaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $this->assertTrue( $provider->providerAllowsPropertyChange( 'foo' ) ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, false ) + ); + + $this->assertNull( + $provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() ) + ); + $provider->autoCreatedAccount( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ); + + $res = AuthenticationResponse::newPass(); + $provider->postAuthentication( $user, $res ); + $provider->postAccountCreation( $user, $user, $res ); + $provider->postAccountLink( $user, $res ); + + $provider->expects( $this->once() ) + ->method( 'testUserExists' ) + ->with( $this->equalTo( 'foo' ) ) + ->will( $this->returnValue( true ) ); + $this->assertTrue( $provider->testUserCanAuthenticate( 'foo' ) ); + } + + public function testProviderRevokeAccessForUser() { + $reqs = []; + for ( $i = 0; $i < 3; $i++ ) { + $reqs[$i] = $this->createMock( AuthenticationRequest::class ); + $reqs[$i]->done = false; + } + + $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $provider->expects( $this->once() )->method( 'getAuthenticationRequests' ) + ->with( + $this->identicalTo( AuthManager::ACTION_REMOVE ), + $this->identicalTo( [ 'username' => 'UTSysop' ] ) + ) + ->will( $this->returnValue( $reqs ) ); + $provider->expects( $this->exactly( 3 ) )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + $this->assertFalse( $req->done ); + $req->done = true; + } ) ); + + $provider->providerRevokeAccessForUser( 'UTSysop' ); + + foreach ( $reqs as $i => $req ) { + $this->assertTrue( $req->done, "#$i" ); + } + } + + /** + * @dataProvider providePrimaryAccountLink + * @param string $type PrimaryAuthenticationProvider::TYPE_* constant + * @param string $msg Error message from beginPrimaryAccountLink + */ + public function testPrimaryAccountLink( $type, $msg ) { + $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $provider->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + + $class = AbstractPrimaryAuthenticationProvider::class; + $msg1 = "{$class}::beginPrimaryAccountLink $msg"; + $msg2 = "{$class}::continuePrimaryAccountLink is not implemented."; + + $user = \User::newFromName( 'Whatever' ); + + try { + $provider->beginPrimaryAccountLink( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( $msg1, $ex->getMessage() ); + } + try { + $provider->continuePrimaryAccountLink( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( $msg2, $ex->getMessage() ); + } + } + + public static function providePrimaryAccountLink() { + return [ + [ + PrimaryAuthenticationProvider::TYPE_NONE, + 'should not be called on a non-link provider.', + ], + [ + PrimaryAuthenticationProvider::TYPE_CREATE, + 'should not be called on a non-link provider.', + ], + [ + PrimaryAuthenticationProvider::TYPE_LINK, + 'is not implemented.', + ], + ]; + } + + /** + * @dataProvider provideProviderNormalizeUsername + */ + public function testProviderNormalizeUsername( $name, $expect ) { + // fake interwiki map for the 'Interwiki prefix' testcase + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'InterwikiLoadPrefix' => [ + function ( $prefix, &$iwdata ) { + if ( $prefix === 'interwiki' ) { + $iwdata = [ + 'iw_url' => 'http://example.com/', + 'iw_local' => 0, + 'iw_trans' => 0, + ]; + return false; + } + }, + ], + ] ); + + $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $this->assertSame( $expect, $provider->providerNormalizeUsername( $name ) ); + } + + public static function provideProviderNormalizeUsername() { + return [ + 'Leading space' => [ ' Leading space', 'Leading space' ], + 'Trailing space ' => [ 'Trailing space ', 'Trailing space' ], + 'Namespace prefix' => [ 'Talk:Username', null ], + 'Interwiki prefix' => [ 'interwiki:Username', null ], + 'With hash' => [ 'name with # hash', null ], + 'Multi spaces' => [ 'Multi spaces', 'Multi spaces' ], + 'Lowercase' => [ 'lowercase', 'Lowercase' ], + 'Invalid character' => [ 'in[]valid', null ], + 'With slash' => [ 'with / slash', null ], + 'Underscores' => [ '___under__scores___', 'Under scores' ], + ]; + } + +} diff --git a/www/wiki/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php new file mode 100644 index 00000000..41cf62ea --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php @@ -0,0 +1,84 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\AbstractSecondaryAuthenticationProvider + */ +class AbstractSecondaryAuthenticationProviderTest extends \MediaWikiTestCase { + public function testAbstractSecondaryAuthenticationProvider() { + $user = \User::newFromName( 'UTSysop' ); + + $provider = $this->getMockForAbstractClass( AbstractSecondaryAuthenticationProvider::class ); + + try { + $provider->continueSecondaryAuthentication( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + try { + $provider->continueSecondaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $this->assertTrue( $provider->providerAllowsPropertyChange( 'foo' ) ); + $this->assertEquals( + \StatusValue::newGood( 'ignored' ), + $provider->providerAllowsAuthenticationDataChange( $req ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, false ) + ); + + $provider->providerChangeAuthenticationData( $req ); + $provider->autoCreatedAccount( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ); + + $res = AuthenticationResponse::newPass(); + $provider->postAuthentication( $user, $res ); + $provider->postAccountCreation( $user, $user, $res ); + } + + public function testProviderRevokeAccessForUser() { + $reqs = []; + for ( $i = 0; $i < 3; $i++ ) { + $reqs[$i] = $this->createMock( AuthenticationRequest::class ); + $reqs[$i]->done = false; + } + + $provider = $this->getMockBuilder( AbstractSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'providerChangeAuthenticationData' ] ) + ->getMockForAbstractClass(); + $provider->expects( $this->once() )->method( 'getAuthenticationRequests' ) + ->with( + $this->identicalTo( AuthManager::ACTION_REMOVE ), + $this->identicalTo( [ 'username' => 'UTSysop' ] ) + ) + ->will( $this->returnValue( $reqs ) ); + $provider->expects( $this->exactly( 3 ) )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + $this->assertFalse( $req->done ); + $req->done = true; + } ) ); + + $provider->providerRevokeAccessForUser( 'UTSysop' ); + + foreach ( $reqs as $i => $req ) { + $this->assertTrue( $req->done, "#$i" ); + } + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/AuthManagerTest.php b/www/wiki/tests/phpunit/includes/auth/AuthManagerTest.php new file mode 100644 index 00000000..cc162487 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/AuthManagerTest.php @@ -0,0 +1,3629 @@ +<?php + +namespace MediaWiki\Auth; + +use Config; +use MediaWiki\Session\SessionInfo; +use MediaWiki\Session\UserInfo; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use StatusValue; +use WebRequest; +use Wikimedia\ScopedCallback; +use Wikimedia\TestingAccessWrapper; + +/** + * @group AuthManager + * @group Database + * @covers MediaWiki\Auth\AuthManager + */ +class AuthManagerTest extends \MediaWikiTestCase { + /** @var WebRequest */ + protected $request; + /** @var Config */ + protected $config; + /** @var LoggerInterface */ + protected $logger; + + protected $preauthMocks = []; + protected $primaryauthMocks = []; + protected $secondaryauthMocks = []; + + /** @var AuthManager */ + protected $manager; + /** @var TestingAccessWrapper */ + protected $managerPriv; + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( [ 'wgAuth' => null ] ); + $this->stashMwGlobals( [ 'wgHooks' ] ); + } + + /** + * Sets a mock on a hook + * @param string $hook + * @param object $expect From $this->once(), $this->never(), etc. + * @return object $mock->expects( $expect )->method( ... ). + */ + protected function hook( $hook, $expect ) { + global $wgHooks; + $mock = $this->getMockBuilder( __CLASS__ ) + ->setMethods( [ "on$hook" ] ) + ->getMock(); + $wgHooks[$hook] = [ $mock ]; + return $mock->expects( $expect )->method( "on$hook" ); + } + + /** + * Unsets a hook + * @param string $hook + */ + protected function unhook( $hook ) { + global $wgHooks; + $wgHooks[$hook] = []; + } + + /** + * Ensure a value is a clean Message object + * @param string|Message $key + * @param array $params + * @return Message + */ + protected function message( $key, $params = [] ) { + if ( $key === null ) { + return null; + } + if ( $key instanceof \MessageSpecifier ) { + $params = $key->getParams(); + $key = $key->getKey(); + } + return new \Message( $key, $params, \Language::factory( 'en' ) ); + } + + /** + * Initialize the AuthManagerConfig variable in $this->config + * + * Uses data from the various 'mocks' fields. + */ + protected function initializeConfig() { + $config = [ + 'preauth' => [ + ], + 'primaryauth' => [ + ], + 'secondaryauth' => [ + ], + ]; + + foreach ( [ 'preauth', 'primaryauth', 'secondaryauth' ] as $type ) { + $key = $type . 'Mocks'; + foreach ( $this->$key as $mock ) { + $config[$type][$mock->getUniqueId()] = [ 'factory' => function () use ( $mock ) { + return $mock; + } ]; + } + } + + $this->config->set( 'AuthManagerConfig', $config ); + $this->config->set( 'LanguageCode', 'en' ); + $this->config->set( 'NewUserLog', false ); + } + + /** + * Initialize $this->manager + * @param bool $regen Force a call to $this->initializeConfig() + */ + protected function initializeManager( $regen = false ) { + if ( $regen || !$this->config ) { + $this->config = new \HashConfig(); + } + if ( $regen || !$this->request ) { + $this->request = new \FauxRequest(); + } + if ( !$this->logger ) { + $this->logger = new \TestLogger(); + } + + if ( $regen || !$this->config->has( 'AuthManagerConfig' ) ) { + $this->initializeConfig(); + } + $this->manager = new AuthManager( $this->request, $this->config ); + $this->manager->setLogger( $this->logger ); + $this->managerPriv = TestingAccessWrapper::newFromObject( $this->manager ); + } + + /** + * Setup SessionManager with a mock session provider + * @param bool|null $canChangeUser If non-null, canChangeUser will be mocked to return this + * @param array $methods Additional methods to mock + * @return array (MediaWiki\Session\SessionProvider, ScopedCallback) + */ + protected function getMockSessionProvider( $canChangeUser = null, array $methods = [] ) { + if ( !$this->config ) { + $this->config = new \HashConfig(); + $this->initializeConfig(); + } + $this->config->set( 'ObjectCacheSessionExpiry', 100 ); + + $methods[] = '__toString'; + $methods[] = 'describe'; + if ( $canChangeUser !== null ) { + $methods[] = 'canChangeUser'; + } + $provider = $this->getMockBuilder( \DummySessionProvider::class ) + ->setMethods( $methods ) + ->getMock(); + $provider->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockSessionProvider' ) ); + $provider->expects( $this->any() )->method( 'describe' ) + ->will( $this->returnValue( 'MockSessionProvider sessions' ) ); + if ( $canChangeUser !== null ) { + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( $canChangeUser ) ); + } + $this->config->set( 'SessionProviders', [ + [ 'factory' => function () use ( $provider ) { + return $provider; + } ], + ] ); + + $manager = new \MediaWiki\Session\SessionManager( [ + 'config' => $this->config, + 'logger' => new \Psr\Log\NullLogger(), + 'store' => new \HashBagOStuff(), + ] ); + TestingAccessWrapper::newFromObject( $manager )->getProvider( (string)$provider ); + + $reset = \MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager ); + + if ( $this->request ) { + $manager->getSessionForRequest( $this->request ); + } + + return [ $provider, $reset ]; + } + + public function testSingleton() { + // Temporarily clear out the global singleton, if any, to test creating + // one. + $rProp = new \ReflectionProperty( AuthManager::class, 'instance' ); + $rProp->setAccessible( true ); + $old = $rProp->getValue(); + $cb = new ScopedCallback( [ $rProp, 'setValue' ], [ $old ] ); + $rProp->setValue( null ); + + $singleton = AuthManager::singleton(); + $this->assertInstanceOf( AuthManager::class, AuthManager::singleton() ); + $this->assertSame( $singleton, AuthManager::singleton() ); + $this->assertSame( \RequestContext::getMain()->getRequest(), $singleton->getRequest() ); + $this->assertSame( + \RequestContext::getMain()->getConfig(), + TestingAccessWrapper::newFromObject( $singleton )->config + ); + } + + public function testCanAuthenticateNow() { + $this->initializeManager(); + + list( $provider, $reset ) = $this->getMockSessionProvider( false ); + $this->assertFalse( $this->manager->canAuthenticateNow() ); + ScopedCallback::consume( $reset ); + + list( $provider, $reset ) = $this->getMockSessionProvider( true ); + $this->assertTrue( $this->manager->canAuthenticateNow() ); + ScopedCallback::consume( $reset ); + } + + public function testNormalizeUsername() { + $mocks = [ + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + ]; + foreach ( $mocks as $key => $mock ) { + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $key ) ); + } + $mocks[0]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( 'Foo' ); + $mocks[1]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( 'Foo' ); + $mocks[2]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( null ); + $mocks[3]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( 'Bar!' ); + + $this->primaryauthMocks = $mocks; + + $this->initializeManager(); + + $this->assertSame( [ 'Foo', 'Bar!' ], $this->manager->normalizeUsername( 'XYZ' ) ); + } + + /** + * @dataProvider provideSecuritySensitiveOperationStatus + * @param bool $mutableSession + */ + public function testSecuritySensitiveOperationStatus( $mutableSession ) { + $this->logger = new \Psr\Log\NullLogger(); + $user = \User::newFromName( 'UTSysop' ); + $provideUser = null; + $reauth = $mutableSession ? AuthManager::SEC_REAUTH : AuthManager::SEC_FAIL; + + list( $provider, $reset ) = $this->getMockSessionProvider( + $mutableSession, [ 'provideSessionInfo' ] + ); + $provider->expects( $this->any() )->method( 'provideSessionInfo' ) + ->will( $this->returnCallback( function () use ( $provider, &$provideUser ) { + return new SessionInfo( SessionInfo::MIN_PRIORITY, [ + 'provider' => $provider, + 'id' => \DummySessionProvider::ID, + 'persisted' => true, + 'userInfo' => UserInfo::newFromUser( $provideUser, true ) + ] ); + } ) ); + $this->initializeManager(); + + $this->config->set( 'ReauthenticateTime', [] ); + $this->config->set( 'AllowSecuritySensitiveOperationIfCannotReauthenticate', [] ); + $provideUser = new \User; + $session = $provider->getManager()->getSessionForRequest( $this->request ); + $this->assertSame( 0, $session->getUser()->getId(), 'sanity check' ); + + // Anonymous user => reauth + $session->set( 'AuthManager:lastAuthId', 0 ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + $this->assertSame( $reauth, $this->manager->securitySensitiveOperationStatus( 'foo' ) ); + + $provideUser = $user; + $session = $provider->getManager()->getSessionForRequest( $this->request ); + $this->assertSame( $user->getId(), $session->getUser()->getId(), 'sanity check' ); + + // Error for no default (only gets thrown for non-anonymous user) + $session->set( 'AuthManager:lastAuthId', $user->getId() + 1 ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + try { + $this->manager->securitySensitiveOperationStatus( 'foo' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + $mutableSession + ? '$wgReauthenticateTime lacks a default' + : '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default', + $ex->getMessage() + ); + } + + if ( $mutableSession ) { + $this->config->set( 'ReauthenticateTime', [ + 'test' => 100, + 'test2' => -1, + 'default' => 10, + ] ); + + // Mismatched user ID + $session->set( 'AuthManager:lastAuthId', $user->getId() + 1 ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test2' ) + ); + + // Missing time + $session->set( 'AuthManager:lastAuthId', $user->getId() ); + $session->set( 'AuthManager:lastAuthTimestamp', null ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test2' ) + ); + + // Recent enough to pass + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + + // Not recent enough to pass + $session->set( 'AuthManager:lastAuthTimestamp', time() - 20 ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + // But recent enough for the 'test' operation + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + } else { + $this->config->set( 'AllowSecuritySensitiveOperationIfCannotReauthenticate', [ + 'test' => false, + 'default' => true, + ] ); + + $this->assertEquals( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + + $this->assertEquals( + AuthManager::SEC_FAIL, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + } + + // Test hook, all three possible values + foreach ( [ + AuthManager::SEC_OK => AuthManager::SEC_OK, + AuthManager::SEC_REAUTH => $reauth, + AuthManager::SEC_FAIL => AuthManager::SEC_FAIL, + ] as $hook => $expect ) { + $this->hook( 'SecuritySensitiveOperationStatus', $this->exactly( 2 ) ) + ->with( + $this->anything(), + $this->anything(), + $this->callback( function ( $s ) use ( $session ) { + return $s->getId() === $session->getId(); + } ), + $mutableSession ? $this->equalTo( 500, 1 ) : $this->equalTo( -1 ) + ) + ->will( $this->returnCallback( function ( &$v ) use ( $hook ) { + $v = $hook; + return true; + } ) ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 500 ); + $this->assertEquals( + $expect, $this->manager->securitySensitiveOperationStatus( 'test' ), "hook $hook" + ); + $this->assertEquals( + $expect, $this->manager->securitySensitiveOperationStatus( 'test2' ), "hook $hook" + ); + $this->unhook( 'SecuritySensitiveOperationStatus' ); + } + + ScopedCallback::consume( $reset ); + } + + public function onSecuritySensitiveOperationStatus( &$status, $operation, $session, $time ) { + } + + public static function provideSecuritySensitiveOperationStatus() { + return [ + [ true ], + [ false ], + ]; + } + + /** + * @dataProvider provideUserCanAuthenticate + * @param bool $primary1Can + * @param bool $primary2Can + * @param bool $expect + */ + public function testUserCanAuthenticate( $primary1Can, $primary2Can, $expect ) { + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary1' ) ); + $mock1->expects( $this->any() )->method( 'testUserCanAuthenticate' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary1Can ) ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $mock2->expects( $this->any() )->method( 'testUserCanAuthenticate' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary2Can ) ); + $this->primaryauthMocks = [ $mock1, $mock2 ]; + + $this->initializeManager( true ); + $this->assertSame( $expect, $this->manager->userCanAuthenticate( 'UTSysop' ) ); + } + + public static function provideUserCanAuthenticate() { + return [ + [ false, false, false ], + [ true, false, true ], + [ false, true, true ], + [ true, true, true ], + ]; + } + + public function testRevokeAccessForUser() { + $this->initializeManager(); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->once() )->method( 'providerRevokeAccessForUser' ) + ->with( $this->equalTo( 'UTSysop' ) ); + $this->primaryauthMocks = [ $mock ]; + + $this->initializeManager( true ); + $this->logger->setCollect( true ); + + $this->manager->revokeAccessForUser( 'UTSysop' ); + + $this->assertSame( [ + [ LogLevel::INFO, 'Revoking access for {user}' ], + ], $this->logger->getBuffer() ); + } + + public function testProviderCreation() { + $mocks = [ + 'pre' => $this->getMockForAbstractClass( PreAuthenticationProvider::class ), + 'primary' => $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + 'secondary' => $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ), + ]; + foreach ( $mocks as $key => $mock ) { + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $key ) ); + $mock->expects( $this->once() )->method( 'setLogger' ); + $mock->expects( $this->once() )->method( 'setManager' ); + $mock->expects( $this->once() )->method( 'setConfig' ); + } + $this->preauthMocks = [ $mocks['pre'] ]; + $this->primaryauthMocks = [ $mocks['primary'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + + // Normal operation + $this->initializeManager(); + $this->assertSame( + $mocks['primary'], + $this->managerPriv->getAuthenticationProvider( 'primary' ) + ); + $this->assertSame( + $mocks['secondary'], + $this->managerPriv->getAuthenticationProvider( 'secondary' ) + ); + $this->assertSame( + $mocks['pre'], + $this->managerPriv->getAuthenticationProvider( 'pre' ) + ); + $this->assertSame( + [ 'pre' => $mocks['pre'] ], + $this->managerPriv->getPreAuthenticationProviders() + ); + $this->assertSame( + [ 'primary' => $mocks['primary'] ], + $this->managerPriv->getPrimaryAuthenticationProviders() + ); + $this->assertSame( + [ 'secondary' => $mocks['secondary'] ], + $this->managerPriv->getSecondaryAuthenticationProviders() + ); + + // Duplicate IDs + $mock1 = $this->getMockForAbstractClass( PreAuthenticationProvider::class ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $this->preauthMocks = [ $mock1 ]; + $this->primaryauthMocks = [ $mock2 ]; + $this->secondaryauthMocks = []; + $this->initializeManager( true ); + try { + $this->managerPriv->getAuthenticationProvider( 'Y' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $class1 = get_class( $mock1 ); + $class2 = get_class( $mock2 ); + $this->assertSame( + "Duplicate specifications for id X (classes $class1 and $class2)", $ex->getMessage() + ); + } + + // Wrong classes + $mock = $this->getMockForAbstractClass( AuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $class = get_class( $mock ); + $this->preauthMocks = [ $mock ]; + $this->primaryauthMocks = [ $mock ]; + $this->secondaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + try { + $this->managerPriv->getPreAuthenticationProviders(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\PreAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + try { + $this->managerPriv->getPrimaryAuthenticationProviders(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + try { + $this->managerPriv->getSecondaryAuthenticationProviders(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\SecondaryAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + + // Sorting + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock3 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'A' ) ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) ); + $mock3->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'C' ) ); + $this->preauthMocks = []; + $this->primaryauthMocks = [ $mock1, $mock2, $mock3 ]; + $this->secondaryauthMocks = []; + $this->initializeConfig(); + $config = $this->config->get( 'AuthManagerConfig' ); + + $this->initializeManager( false ); + $this->assertSame( + [ 'A' => $mock1, 'B' => $mock2, 'C' => $mock3 ], + $this->managerPriv->getPrimaryAuthenticationProviders(), + 'sanity check' + ); + + $config['primaryauth']['A']['sort'] = 100; + $config['primaryauth']['C']['sort'] = -1; + $this->config->set( 'AuthManagerConfig', $config ); + $this->initializeManager( false ); + $this->assertSame( + [ 'C' => $mock3, 'B' => $mock2, 'A' => $mock1 ], + $this->managerPriv->getPrimaryAuthenticationProviders() + ); + } + + public function testSetDefaultUserOptions() { + $this->initializeManager(); + + $context = \RequestContext::getMain(); + $reset = new ScopedCallback( [ $context, 'setLanguage' ], [ $context->getLanguage() ] ); + $context->setLanguage( 'de' ); + $this->setMwGlobals( 'wgContLang', \Language::factory( 'zh' ) ); + + $user = \User::newFromName( self::usernameForCreation() ); + $user->addToDatabase(); + $oldToken = $user->getToken(); + $this->managerPriv->setDefaultUserOptions( $user, false ); + $user->saveSettings(); + $this->assertNotEquals( $oldToken, $user->getToken() ); + $this->assertSame( 'zh', $user->getOption( 'language' ) ); + $this->assertSame( 'zh', $user->getOption( 'variant' ) ); + + $user = \User::newFromName( self::usernameForCreation() ); + $user->addToDatabase(); + $oldToken = $user->getToken(); + $this->managerPriv->setDefaultUserOptions( $user, true ); + $user->saveSettings(); + $this->assertNotEquals( $oldToken, $user->getToken() ); + $this->assertSame( 'de', $user->getOption( 'language' ) ); + $this->assertSame( 'zh', $user->getOption( 'variant' ) ); + + $this->setMwGlobals( 'wgContLang', \Language::factory( 'fr' ) ); + + $user = \User::newFromName( self::usernameForCreation() ); + $user->addToDatabase(); + $oldToken = $user->getToken(); + $this->managerPriv->setDefaultUserOptions( $user, true ); + $user->saveSettings(); + $this->assertNotEquals( $oldToken, $user->getToken() ); + $this->assertSame( 'de', $user->getOption( 'language' ) ); + $this->assertSame( null, $user->getOption( 'variant' ) ); + } + + public function testForcePrimaryAuthenticationProviders() { + $mockA = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mockB = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mockB2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mockA->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'A' ) ); + $mockB->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) ); + $mockB2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) ); + $this->primaryauthMocks = [ $mockA ]; + + $this->logger = new \TestLogger( true ); + + // Test without first initializing the configured providers + $this->initializeManager(); + $this->manager->forcePrimaryAuthenticationProviders( [ $mockB ], 'testing' ); + $this->assertSame( + [ 'B' => $mockB ], $this->managerPriv->getPrimaryAuthenticationProviders() + ); + $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'A' ) ); + $this->assertSame( $mockB, $this->managerPriv->getAuthenticationProvider( 'B' ) ); + $this->assertSame( [ + [ LogLevel::WARNING, 'Overriding AuthManager primary authn because testing' ], + ], $this->logger->getBuffer() ); + $this->logger->clearBuffer(); + + // Test with first initializing the configured providers + $this->initializeManager(); + $this->assertSame( $mockA, $this->managerPriv->getAuthenticationProvider( 'A' ) ); + $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'B' ) ); + $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' ); + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', 'test' ); + $this->manager->forcePrimaryAuthenticationProviders( [ $mockB ], 'testing' ); + $this->assertSame( + [ 'B' => $mockB ], $this->managerPriv->getPrimaryAuthenticationProviders() + ); + $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'A' ) ); + $this->assertSame( $mockB, $this->managerPriv->getAuthenticationProvider( 'B' ) ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + $this->assertSame( [ + [ LogLevel::WARNING, 'Overriding AuthManager primary authn because testing' ], + [ + LogLevel::WARNING, + 'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.' + ], + ], $this->logger->getBuffer() ); + $this->logger->clearBuffer(); + + // Test duplicate IDs + $this->initializeManager(); + try { + $this->manager->forcePrimaryAuthenticationProviders( [ $mockB, $mockB2 ], 'testing' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $class1 = get_class( $mockB ); + $class2 = get_class( $mockB2 ); + $this->assertSame( + "Duplicate specifications for id B (classes $class2 and $class1)", $ex->getMessage() + ); + } + + // Wrong classes + $mock = $this->getMockForAbstractClass( AuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $class = get_class( $mock ); + try { + $this->manager->forcePrimaryAuthenticationProviders( [ $mock ], 'testing' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + } + + public function testBeginAuthentication() { + $this->initializeManager(); + + // Immutable session + list( $provider, $reset ) = $this->getMockSessionProvider( false ); + $this->hook( 'UserLoggedIn', $this->never() ); + $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' ); + try { + $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertSame( 'Authentication is not possible now', $ex->getMessage() ); + } + $this->unhook( 'UserLoggedIn' ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) ); + ScopedCallback::consume( $reset ); + $this->initializeManager( true ); + + // CreatedAccountAuthenticationRequest + $user = \User::newFromName( 'UTSysop' ); + $reqs = [ + new CreatedAccountAuthenticationRequest( $user->getId(), $user->getName() ) + ]; + $this->hook( 'UserLoggedIn', $this->never() ); + try { + $this->manager->beginAuthentication( $reqs, 'http://localhost/' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertSame( + 'CreatedAccountAuthenticationRequests are only valid on the same AuthManager ' . + 'that created the account', + $ex->getMessage() + ); + } + $this->unhook( 'UserLoggedIn' ); + + $this->request->getSession()->clear(); + $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' ); + $this->managerPriv->createdAccountAuthenticationRequests = [ $reqs[0] ]; + $this->hook( 'UserLoggedIn', $this->once() ) + ->with( $this->callback( function ( $u ) use ( $user ) { + return $user->getId() === $u->getId() && $user->getName() === $u->getName(); + } ) ); + $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->once() ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAuthentication( $reqs, 'http://localhost/' ); + $this->logger->setCollect( false ); + $this->unhook( 'UserLoggedIn' ); + $this->unhook( 'AuthManagerLoginAuthenticateAudit' ); + $this->assertSame( AuthenticationResponse::PASS, $ret->status ); + $this->assertSame( $user->getName(), $ret->username ); + $this->assertSame( $user->getId(), $this->request->getSessionData( 'AuthManager:lastAuthId' ) ); + $this->assertEquals( + time(), $this->request->getSessionData( 'AuthManager:lastAuthTimestamp' ), + 'timestamp ±1', 1 + ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) ); + $this->assertSame( $user->getId(), $this->request->getSession()->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'Logging in {user} after account creation' ], + ], $this->logger->getBuffer() ); + } + + public function testCreateFromLogin() { + $user = \User::newFromName( 'UTSysop' ); + $req1 = $this->createMock( AuthenticationRequest::class ); + $req2 = $this->createMock( AuthenticationRequest::class ); + $req3 = $this->createMock( AuthenticationRequest::class ); + $userReq = new UsernameAuthenticationRequest; + $userReq->username = 'UTDummy'; + + $req1->returnToUrl = 'http://localhost/'; + $req2->returnToUrl = 'http://localhost/'; + $req3->returnToUrl = 'http://localhost/'; + $req3->username = 'UTDummy'; + $userReq->returnToUrl = 'http://localhost/'; + + // Passing one into beginAuthentication(), and an immediate FAIL + $primary = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $this->primaryauthMocks = [ $primary ]; + $this->initializeManager( true ); + $res = AuthenticationResponse::newFail( wfMessage( 'foo' ) ); + $res->createRequest = $req1; + $primary->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( $res ) ); + $createReq = new CreateFromLoginAuthenticationRequest( + null, [ $req2->getUniqueId() => $req2 ] + ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAuthentication( [ $createReq ], 'http://localhost/' ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->createRequest ); + $this->assertSame( $req1, $ret->createRequest->createRequest ); + $this->assertEquals( [ $req2->getUniqueId() => $req2 ], $ret->createRequest->maybeLink ); + + // UI, then FAIL in beginAuthentication() + $primary = $this->getMockBuilder( AbstractPrimaryAuthenticationProvider::class ) + ->setMethods( [ 'continuePrimaryAuthentication' ] ) + ->getMockForAbstractClass(); + $this->primaryauthMocks = [ $primary ]; + $this->initializeManager( true ); + $primary->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( + AuthenticationResponse::newUI( [ $req1 ], wfMessage( 'foo' ) ) + ) ); + $res = AuthenticationResponse::newFail( wfMessage( 'foo' ) ); + $res->createRequest = $req2; + $primary->expects( $this->any() )->method( 'continuePrimaryAuthentication' ) + ->will( $this->returnValue( $res ) ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->assertSame( AuthenticationResponse::UI, $ret->status, 'sanity check' ); + $ret = $this->manager->continueAuthentication( [] ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->createRequest ); + $this->assertSame( $req2, $ret->createRequest->createRequest ); + $this->assertEquals( [], $ret->createRequest->maybeLink ); + + // Pass into beginAccountCreation(), see that maybeLink and createRequest get copied + $primary = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $this->primaryauthMocks = [ $primary ]; + $this->initializeManager( true ); + $createReq = new CreateFromLoginAuthenticationRequest( $req3, [ $req2 ] ); + $createReq->returnToUrl = 'http://localhost/'; + $createReq->username = 'UTDummy'; + $res = AuthenticationResponse::newUI( [ $req1 ], wfMessage( 'foo' ) ); + $primary->expects( $this->any() )->method( 'beginPrimaryAccountCreation' ) + ->with( $this->anything(), $this->anything(), [ $userReq, $createReq, $req3 ] ) + ->will( $this->returnValue( $res ) ); + $primary->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAccountCreation( + $user, [ $userReq, $createReq ], 'http://localhost/' + ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::UI, $ret->status ); + $state = $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ); + $this->assertNotNull( $state ); + $this->assertEquals( [ $userReq, $createReq, $req3 ], $state['reqs'] ); + $this->assertEquals( [ $req2 ], $state['maybeLink'] ); + } + + /** + * @dataProvider provideAuthentication + * @param StatusValue $preResponse + * @param array $primaryResponses + * @param array $secondaryResponses + * @param array $managerResponses + * @param bool $link Whether the primary authentication provider is a "link" provider + */ + public function testAuthentication( + StatusValue $preResponse, array $primaryResponses, array $secondaryResponses, + array $managerResponses, $link = false + ) { + $this->initializeManager(); + $user = \User::newFromName( 'UTSysop' ); + $id = $user->getId(); + $name = $user->getName(); + + // Set up lots of mocks... + $req = new RememberMeAuthenticationRequest; + $req->rememberMe = (bool)rand( 0, 1 ); + $req->pre = $preResponse; + $req->primary = $primaryResponses; + $req->secondary = $secondaryResponses; + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key . '2'] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" ); + $mocks[$key . '2']->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . '2' ) ); + $mocks[$key . '3'] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" ); + $mocks[$key . '3']->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . '3' ) ); + } + foreach ( $mocks as $mock ) { + $mock->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnValue( [] ) ); + } + + $mocks['pre']->expects( $this->once() )->method( 'testForAuthentication' ) + ->will( $this->returnCallback( function ( $reqs ) use ( $req ) { + $this->assertContains( $req, $reqs ); + return $req->pre; + } ) ); + + $ct = count( $req->primary ); + $callback = $this->returnCallback( function ( $reqs ) use ( $req ) { + $this->assertContains( $req, $reqs ); + return array_shift( $req->primary ); + } ); + $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginPrimaryAuthentication' ) + ->will( $callback ); + $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continuePrimaryAuthentication' ) + ->will( $callback ); + if ( $link ) { + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + } + + $ct = count( $req->secondary ); + $callback = $this->returnCallback( function ( $user, $reqs ) use ( $id, $name, $req ) { + $this->assertSame( $id, $user->getId() ); + $this->assertSame( $name, $user->getName() ); + $this->assertContains( $req, $reqs ); + return array_shift( $req->secondary ); + } ); + $mocks['secondary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginSecondaryAuthentication' ) + ->will( $callback ); + $mocks['secondary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continueSecondaryAuthentication' ) + ->will( $callback ); + + $abstain = AuthenticationResponse::newAbstain(); + $mocks['pre2']->expects( $this->atMost( 1 ) )->method( 'testForAuthentication' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAuthentication' ); + $mocks['secondary2']->expects( $this->atMost( 1 ) )->method( 'beginSecondaryAuthentication' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary2']->expects( $this->never() )->method( 'continueSecondaryAuthentication' ); + $mocks['secondary3']->expects( $this->atMost( 1 ) )->method( 'beginSecondaryAuthentication' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary3']->expects( $this->never() )->method( 'continueSecondaryAuthentication' ); + + $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ]; + $this->primaryauthMocks = [ $mocks['primary'], $mocks['primary2'] ]; + $this->secondaryauthMocks = [ + $mocks['secondary3'], $mocks['secondary'], $mocks['secondary2'], + // So linking happens + new ConfirmLinkSecondaryAuthenticationProvider, + ]; + $this->initializeManager( true ); + $this->logger->setCollect( true ); + + $constraint = \PHPUnit_Framework_Assert::logicalOr( + $this->equalTo( AuthenticationResponse::PASS ), + $this->equalTo( AuthenticationResponse::FAIL ) + ); + $providers = array_filter( + array_merge( + $this->preauthMocks, $this->primaryauthMocks, $this->secondaryauthMocks + ), + function ( $p ) { + return is_callable( [ $p, 'expects' ] ); + } + ); + foreach ( $providers as $p ) { + $p->postCalled = false; + $p->expects( $this->atMost( 1 ) )->method( 'postAuthentication' ) + ->willReturnCallback( function ( $user, $response ) use ( $constraint, $p ) { + if ( $user !== null ) { + $this->assertInstanceOf( \User::class, $user ); + $this->assertSame( 'UTSysop', $user->getName() ); + } + $this->assertInstanceOf( AuthenticationResponse::class, $response ); + $this->assertThat( $response->status, $constraint ); + $p->postCalled = $response->status; + } ); + } + + $session = $this->request->getSession(); + $session->setRememberUser( !$req->rememberMe ); + + foreach ( $managerResponses as $i => $response ) { + $success = $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::PASS; + if ( $success ) { + $this->hook( 'UserLoggedIn', $this->once() ) + ->with( $this->callback( function ( $user ) use ( $id, $name ) { + return $user->getId() === $id && $user->getName() === $name; + } ) ); + } else { + $this->hook( 'UserLoggedIn', $this->never() ); + } + if ( $success || ( + $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::FAIL && + $response->message->getKey() !== 'authmanager-authn-not-in-progress' && + $response->message->getKey() !== 'authmanager-authn-no-primary' + ) + ) { + $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->once() ); + } else { + $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->never() ); + } + + $ex = null; + try { + if ( !$i ) { + $ret = $this->manager->beginAuthentication( [ $req ], 'http://localhost/' ); + } else { + $ret = $this->manager->continueAuthentication( [ $req ] ); + } + if ( $response instanceof \Exception ) { + $this->fail( 'Expected exception not thrown', "Response $i" ); + } + } catch ( \Exception $ex ) { + if ( !$response instanceof \Exception ) { + throw $ex; + } + $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" ); + $this->assertNull( $session->getSecret( 'AuthManager::authnState' ), + "Response $i, exception, session state" ); + $this->unhook( 'UserLoggedIn' ); + $this->unhook( 'AuthManagerLoginAuthenticateAudit' ); + return; + } + + $this->unhook( 'UserLoggedIn' ); + $this->unhook( 'AuthManagerLoginAuthenticateAudit' ); + + $this->assertSame( 'http://localhost/', $req->returnToUrl ); + + $ret->message = $this->message( $ret->message ); + $this->assertEquals( $response, $ret, "Response $i, response" ); + if ( $success ) { + $this->assertSame( $id, $session->getUser()->getId(), + "Response $i, authn" ); + } else { + $this->assertSame( 0, $session->getUser()->getId(), + "Response $i, authn" ); + } + if ( $success || $response->status === AuthenticationResponse::FAIL ) { + $this->assertNull( $session->getSecret( 'AuthManager::authnState' ), + "Response $i, session state" ); + foreach ( $providers as $p ) { + $this->assertSame( $response->status, $p->postCalled, + "Response $i, post-auth callback called" ); + } + } else { + $this->assertNotNull( $session->getSecret( 'AuthManager::authnState' ), + "Response $i, session state" ); + foreach ( $ret->neededRequests as $neededReq ) { + $this->assertEquals( AuthManager::ACTION_LOGIN, $neededReq->action, + "Response $i, neededRequest action" ); + } + $this->assertEquals( + $ret->neededRequests, + $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN_CONTINUE ), + "Response $i, continuation check" + ); + foreach ( $providers as $p ) { + $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" ); + } + } + + $state = $session->getSecret( 'AuthManager::authnState' ); + $maybeLink = isset( $state['maybeLink'] ) ? $state['maybeLink'] : []; + if ( $link && $response->status === AuthenticationResponse::RESTART ) { + $this->assertEquals( + $response->createRequest->maybeLink, + $maybeLink, + "Response $i, maybeLink" + ); + } else { + $this->assertEquals( [], $maybeLink, "Response $i, maybeLink" ); + } + } + + if ( $success ) { + $this->assertSame( $req->rememberMe, $session->shouldRememberUser(), + 'rememberMe checkbox had effect' ); + } else { + $this->assertNotSame( $req->rememberMe, $session->shouldRememberUser(), + 'rememberMe checkbox wasn\'t applied' ); + } + } + + public function provideAuthentication() { + $rememberReq = new RememberMeAuthenticationRequest; + $rememberReq->action = AuthManager::ACTION_LOGIN; + + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->foobar = 'baz'; + $restartResponse = AuthenticationResponse::newRestart( + $this->message( 'authmanager-authn-no-local-user' ) + ); + $restartResponse->neededRequests = [ $rememberReq ]; + + $restartResponse2Pass = AuthenticationResponse::newPass( null ); + $restartResponse2Pass->linkRequest = $req; + $restartResponse2 = AuthenticationResponse::newRestart( + $this->message( 'authmanager-authn-no-local-user-link' ) + ); + $restartResponse2->createRequest = new CreateFromLoginAuthenticationRequest( + null, [ $req->getUniqueId() => $req ] + ); + $restartResponse2->createRequest->action = AuthManager::ACTION_LOGIN; + $restartResponse2->neededRequests = [ $rememberReq, $restartResponse2->createRequest ]; + + $userName = 'UTSysop'; + + return [ + 'Failure in pre-auth' => [ + StatusValue::newFatal( 'fail-from-pre' ), + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ), + AuthenticationResponse::newFail( + $this->message( 'authmanager-authn-not-in-progress' ) + ), + ] + ], + 'Failure in primary' => [ + StatusValue::newGood(), + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ], + [], + $tmp + ], + 'All primary abstain' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newAbstain(), + ], + [], + [ + AuthenticationResponse::newFail( $this->message( 'authmanager-authn-no-primary' ) ) + ] + ], + 'Primary UI, then redirect, then fail' => [ + StatusValue::newGood(), + $tmp = [ + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ), + AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ), + ], + [], + $tmp + ], + 'Primary redirect, then abstain' => [ + StatusValue::newGood(), + [ + $tmp = AuthenticationResponse::newRedirect( + [ $req ], '/foo.html', [ 'foo' => 'bar' ] + ), + AuthenticationResponse::newAbstain(), + ], + [], + [ + $tmp, + new \DomainException( + 'MockPrimaryAuthenticationProvider::continuePrimaryAuthentication() returned ABSTAIN' + ) + ] + ], + 'Primary UI, then pass with no local user' => [ + StatusValue::newGood(), + [ + $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass( null ), + ], + [], + [ + $tmp, + $restartResponse, + ] + ], + 'Primary UI, then pass with no local user (link type)' => [ + StatusValue::newGood(), + [ + $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + $restartResponse2Pass, + ], + [], + [ + $tmp, + $restartResponse2, + ], + true + ], + 'Primary pass with invalid username' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( '<>' ), + ], + [], + [ + new \DomainException( 'MockPrimaryAuthenticationProvider returned an invalid username: <>' ), + ] + ], + 'Secondary fail' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( $userName ), + ], + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-in-secondary' ) ), + ], + $tmp + ], + 'Secondary UI, then abstain' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( $userName ), + ], + [ + $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newAbstain() + ], + [ + $tmp, + AuthenticationResponse::newPass( $userName ), + ] + ], + 'Secondary pass' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( $userName ), + ], + [ + AuthenticationResponse::newPass() + ], + [ + AuthenticationResponse::newPass( $userName ), + ] + ], + ]; + } + + /** + * @dataProvider provideUserExists + * @param bool $primary1Exists + * @param bool $primary2Exists + * @param bool $expect + */ + public function testUserExists( $primary1Exists, $primary2Exists, $expect ) { + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary1' ) ); + $mock1->expects( $this->any() )->method( 'testUserExists' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary1Exists ) ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $mock2->expects( $this->any() )->method( 'testUserExists' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary2Exists ) ); + $this->primaryauthMocks = [ $mock1, $mock2 ]; + + $this->initializeManager( true ); + $this->assertSame( $expect, $this->manager->userExists( 'UTSysop' ) ); + } + + public static function provideUserExists() { + return [ + [ false, false, false ], + [ true, false, true ], + [ false, true, true ], + [ true, true, true ], + ]; + } + + /** + * @dataProvider provideAllowsAuthenticationDataChange + * @param StatusValue $primaryReturn + * @param StatusValue $secondaryReturn + * @param Status $expect + */ + public function testAllowsAuthenticationDataChange( $primaryReturn, $secondaryReturn, $expect ) { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '1' ) ); + $mock1->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->with( $this->equalTo( $req ) ) + ->will( $this->returnValue( $primaryReturn ) ); + $mock2 = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '2' ) ); + $mock2->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->with( $this->equalTo( $req ) ) + ->will( $this->returnValue( $secondaryReturn ) ); + + $this->primaryauthMocks = [ $mock1 ]; + $this->secondaryauthMocks = [ $mock2 ]; + $this->initializeManager( true ); + $this->assertEquals( $expect, $this->manager->allowsAuthenticationDataChange( $req ) ); + } + + public static function provideAllowsAuthenticationDataChange() { + $ignored = \Status::newGood( 'ignored' ); + $ignored->warning( 'authmanager-change-not-supported' ); + + $okFromPrimary = StatusValue::newGood(); + $okFromPrimary->warning( 'warning-from-primary' ); + $okFromSecondary = StatusValue::newGood(); + $okFromSecondary->warning( 'warning-from-secondary' ); + + return [ + [ + StatusValue::newGood(), + StatusValue::newGood(), + \Status::newGood(), + ], + [ + StatusValue::newGood(), + StatusValue::newGood( 'ignore' ), + \Status::newGood(), + ], + [ + StatusValue::newGood( 'ignored' ), + StatusValue::newGood(), + \Status::newGood(), + ], + [ + StatusValue::newGood( 'ignored' ), + StatusValue::newGood( 'ignored' ), + $ignored, + ], + [ + StatusValue::newFatal( 'fail from primary' ), + StatusValue::newGood(), + \Status::newFatal( 'fail from primary' ), + ], + [ + $okFromPrimary, + StatusValue::newGood(), + \Status::wrap( $okFromPrimary ), + ], + [ + StatusValue::newGood(), + StatusValue::newFatal( 'fail from secondary' ), + \Status::newFatal( 'fail from secondary' ), + ], + [ + StatusValue::newGood(), + $okFromSecondary, + \Status::wrap( $okFromSecondary ), + ], + ]; + } + + public function testChangeAuthenticationData() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->username = 'UTSysop'; + + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '1' ) ); + $mock1->expects( $this->once() )->method( 'providerChangeAuthenticationData' ) + ->with( $this->equalTo( $req ) ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '2' ) ); + $mock2->expects( $this->once() )->method( 'providerChangeAuthenticationData' ) + ->with( $this->equalTo( $req ) ); + + $this->primaryauthMocks = [ $mock1, $mock2 ]; + $this->initializeManager( true ); + $this->logger->setCollect( true ); + $this->manager->changeAuthenticationData( $req ); + $this->assertSame( [ + [ LogLevel::INFO, 'Changing authentication data for {user} class {what}' ], + ], $this->logger->getBuffer() ); + } + + public function testCanCreateAccounts() { + $types = [ + PrimaryAuthenticationProvider::TYPE_CREATE => true, + PrimaryAuthenticationProvider::TYPE_LINK => true, + PrimaryAuthenticationProvider::TYPE_NONE => false, + ]; + + foreach ( $types as $type => $can ) { + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $type ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->assertSame( $can, $this->manager->canCreateAccounts(), $type ); + } + } + + public function testCheckAccountCreatePermissions() { + global $wgGroupPermissions; + + $this->stashMwGlobals( [ 'wgGroupPermissions' ] ); + + $this->initializeManager( true ); + + $wgGroupPermissions['*']['createaccount'] = true; + $this->assertEquals( + \Status::newGood(), + $this->manager->checkAccountCreatePermissions( new \User ) + ); + + $readOnlyMode = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode(); + $readOnlyMode->setReason( 'Because' ); + $this->assertEquals( + \Status::newFatal( wfMessage( 'readonlytext', 'Because' ) ), + $this->manager->checkAccountCreatePermissions( new \User ) + ); + $readOnlyMode->setReason( false ); + + $wgGroupPermissions['*']['createaccount'] = false; + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'badaccess-groups' ) ); + $wgGroupPermissions['*']['createaccount'] = true; + + $user = \User::newFromName( 'UTBlockee' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + \TestUser::setPasswordForUser( $user, 'UTBlockeePassword' ); + $user->saveSettings(); + } + $oldBlock = \Block::newFromTarget( 'UTBlockee' ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + $blockOptions = [ + 'address' => 'UTBlockee', + 'user' => $user->getID(), + 'by' => $this->getTestSysop()->getUser()->getId(), + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + $status = $this->manager->checkAccountCreatePermissions( $user ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) ); + + $blockOptions = [ + 'address' => '127.0.0.0/24', + 'by' => $this->getTestSysop()->getUser()->getId(), + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + $scopeVariable = new ScopedCallback( [ $block, 'delete' ] ); + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) ); + ScopedCallback::consume( $scopeVariable ); + + $this->setMwGlobals( [ + 'wgEnableDnsBlacklist' => true, + 'wgDnsBlacklistUrls' => [ + 'local.wmftest.net', // This will resolve for every subdomain, which works to test "listed?" + ], + 'wgProxyWhitelist' => [], + ] ); + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'sorbs_create_account_reason' ) ); + $this->setMwGlobals( 'wgProxyWhitelist', [ '127.0.0.1' ] ); + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertTrue( $status->isGood() ); + } + + /** + * @param string $uniq + * @return string + */ + private static function usernameForCreation( $uniq = '' ) { + $i = 0; + do { + $username = "UTAuthManagerTestAccountCreation" . $uniq . ++$i; + } while ( \User::newFromName( $username )->getId() !== 0 ); + return $username; + } + + public function testCanCreateAccount() { + $username = self::usernameForCreation(); + $this->initializeManager(); + + $this->assertEquals( + \Status::newFatal( 'authmanager-create-disabled' ), + $this->manager->canCreateAccount( $username ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->assertEquals( + \Status::newFatal( 'userexists' ), + $this->manager->canCreateAccount( $username ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->assertEquals( + \Status::newFatal( 'noname' ), + $this->manager->canCreateAccount( $username . '<>' ) + ); + + $this->assertEquals( + \Status::newFatal( 'userexists' ), + $this->manager->canCreateAccount( 'UTSysop' ) + ); + + $this->assertEquals( + \Status::newGood(), + $this->manager->canCreateAccount( $username ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->assertEquals( + \Status::newFatal( 'fail' ), + $this->manager->canCreateAccount( $username ) + ); + } + + public function testBeginAccountCreation() { + $creator = \User::newFromName( 'UTSysop' ); + $userReq = new UsernameAuthenticationRequest; + $this->logger = new \TestLogger( false, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $this->initializeManager(); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', 'test' ); + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $this->manager->beginAccountCreation( + $creator, [], 'http://localhost/' + ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account creation is not possible', $ex->getMessage() ); + } + $this->unhook( 'LocalUserCreated' ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->beginAccountCreation( $creator, [], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $userReq2 = new UsernameAuthenticationRequest; + $userReq2->username = $userReq->username . 'X'; + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $userReq2 ], 'http://localhost/' + ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $readOnlyMode = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode(); + $readOnlyMode->setReason( 'Because' ); + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'readonlytext', $ret->message->getKey() ); + $this->assertSame( [ 'Because' ], $ret->message->getParams() ); + $readOnlyMode->setReason( false ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'userexists', $ret->message->getKey() ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'fail', $ret->message->getKey() ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation() . '<>'; + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = $creator->getName(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'userexists', $ret->message->getKey() ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mock->expects( $this->any() )->method( 'testForAccountCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $req = $this->getMockBuilder( UserDataAuthenticationRequest::class ) + ->setMethods( [ 'populateUser' ] ) + ->getMock(); + $req->expects( $this->any() )->method( 'populateUser' ) + ->willReturn( \StatusValue::newFatal( 'populatefail' ) ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $req ], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'populatefail', $ret->message->getKey() ); + + $req = new UserDataAuthenticationRequest; + $userReq->username = self::usernameForCreation(); + + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $req ], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'fail', $ret->message->getKey() ); + + $this->manager->beginAccountCreation( + \User::newFromName( $userReq->username ), [ $userReq, $req ], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'fail', $ret->message->getKey() ); + } + + public function testContinueAccountCreation() { + $creator = \User::newFromName( 'UTSysop' ); + $username = self::usernameForCreation(); + $this->logger = new \TestLogger( false, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $this->initializeManager(); + + $session = [ + 'userid' => 0, + 'username' => $username, + 'creatorid' => 0, + 'creatorname' => $username, + 'reqs' => [], + 'primary' => null, + 'primaryResponse' => null, + 'secondary' => [], + 'ranPreTests' => true, + ]; + + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $this->manager->continueAccountCreation( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account creation is not possible', $ex->getMessage() ); + } + $this->unhook( 'LocalUserCreated' ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAccountCreation' )->will( + $this->returnValue( AuthenticationResponse::newFail( $this->message( 'fail' ) ) ) + ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', null ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-create-not-in-progress', $ret->message->getKey() ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => "$username<>" ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + $cache = \ObjectCache::getLocalClusterInstance(); + $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) ); + $ret = $this->manager->continueAccountCreation( [] ); + unset( $lock ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'usernameinprogress', $ret->message->getKey() ); + // This error shouldn't remove the existing session, because the + // raced-with process "owns" it. + $this->assertSame( + $session, $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => $creator->getName() ] + $session ); + $readOnlyMode = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode(); + $readOnlyMode->setReason( 'Because' ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'readonlytext', $ret->message->getKey() ); + $this->assertSame( [ 'Because' ], $ret->message->getParams() ); + $readOnlyMode->setReason( false ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => $creator->getName() ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'userexists', $ret->message->getKey() ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'userid' => $creator->getId() ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $ret = $this->manager->continueAccountCreation( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertEquals( "User \"{$username}\" should exist now, but doesn't!", $ex->getMessage() ); + } + $this->unhook( 'LocalUserCreated' ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $id = $creator->getId(); + $name = $creator->getName(); + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => $name, 'userid' => $id + 1 ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $ret = $this->manager->continueAccountCreation( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertEquals( + "User \"{$name}\" exists, but ID $id != " . ( $id + 1 ) . '!', $ex->getMessage() + ); + } + $this->unhook( 'LocalUserCreated' ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $req = $this->getMockBuilder( UserDataAuthenticationRequest::class ) + ->setMethods( [ 'populateUser' ] ) + ->getMock(); + $req->expects( $this->any() )->method( 'populateUser' ) + ->willReturn( \StatusValue::newFatal( 'populatefail' ) ); + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'reqs' => [ $req ] ] + $session ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'populatefail', $ret->message->getKey() ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + } + + /** + * @dataProvider provideAccountCreation + * @param StatusValue $preTest + * @param StatusValue $primaryTest + * @param StatusValue $secondaryTest + * @param array $primaryResponses + * @param array $secondaryResponses + * @param array $managerResponses + */ + public function testAccountCreation( + StatusValue $preTest, $primaryTest, $secondaryTest, + array $primaryResponses, array $secondaryResponses, array $managerResponses + ) { + $creator = \User::newFromName( 'UTSysop' ); + $username = self::usernameForCreation(); + + $this->initializeManager(); + + // Set up lots of mocks... + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->preTest = $preTest; + $req->primaryTest = $primaryTest; + $req->secondaryTest = $secondaryTest; + $req->primary = $primaryResponses; + $req->secondary = $secondaryResponses; + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key]->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mocks[$key]->expects( $this->any() )->method( 'testForAccountCreation' ) + ->will( $this->returnCallback( + function ( $user, $creatorIn, $reqs ) + use ( $username, $creator, $req, $key ) + { + $this->assertSame( $username, $user->getName() ); + $this->assertSame( $creator->getId(), $creatorIn->getId() ); + $this->assertSame( $creator->getName(), $creatorIn->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $username, $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + $k = $key . 'Test'; + return $req->$k; + } + ) ); + + for ( $i = 2; $i <= 3; $i++ ) { + $mocks[$key . $i] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" ); + $mocks[$key . $i]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . $i ) ); + $mocks[$key . $i]->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mocks[$key . $i]->expects( $this->atMost( 1 ) )->method( 'testForAccountCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + } + } + + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mocks['primary']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $ct = count( $req->primary ); + $callback = $this->returnCallback( function ( $user, $creator, $reqs ) use ( $username, $req ) { + $this->assertSame( $username, $user->getName() ); + $this->assertSame( 'UTSysop', $creator->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $username, $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + return array_shift( $req->primary ); + } ); + $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginPrimaryAccountCreation' ) + ->will( $callback ); + $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continuePrimaryAccountCreation' ) + ->will( $callback ); + + $ct = count( $req->secondary ); + $callback = $this->returnCallback( function ( $user, $creator, $reqs ) use ( $username, $req ) { + $this->assertSame( $username, $user->getName() ); + $this->assertSame( 'UTSysop', $creator->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $username, $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + return array_shift( $req->secondary ); + } ); + $mocks['secondary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginSecondaryAccountCreation' ) + ->will( $callback ); + $mocks['secondary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continueSecondaryAccountCreation' ) + ->will( $callback ); + + $abstain = AuthenticationResponse::newAbstain(); + $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mocks['primary2']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAccountCreation' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAccountCreation' ); + $mocks['primary3']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_NONE ) ); + $mocks['primary3']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $mocks['primary3']->expects( $this->never() )->method( 'beginPrimaryAccountCreation' ); + $mocks['primary3']->expects( $this->never() )->method( 'continuePrimaryAccountCreation' ); + $mocks['secondary2']->expects( $this->atMost( 1 ) ) + ->method( 'beginSecondaryAccountCreation' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary2']->expects( $this->never() )->method( 'continueSecondaryAccountCreation' ); + $mocks['secondary3']->expects( $this->atMost( 1 ) ) + ->method( 'beginSecondaryAccountCreation' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary3']->expects( $this->never() )->method( 'continueSecondaryAccountCreation' ); + + $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ]; + $this->primaryauthMocks = [ $mocks['primary3'], $mocks['primary'], $mocks['primary2'] ]; + $this->secondaryauthMocks = [ + $mocks['secondary3'], $mocks['secondary'], $mocks['secondary2'] + ]; + + $this->logger = new \TestLogger( true, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $expectLog = []; + $this->initializeManager( true ); + + $constraint = \PHPUnit_Framework_Assert::logicalOr( + $this->equalTo( AuthenticationResponse::PASS ), + $this->equalTo( AuthenticationResponse::FAIL ) + ); + $providers = array_merge( + $this->preauthMocks, $this->primaryauthMocks, $this->secondaryauthMocks + ); + foreach ( $providers as $p ) { + $p->postCalled = false; + $p->expects( $this->atMost( 1 ) )->method( 'postAccountCreation' ) + ->willReturnCallback( function ( $user, $creator, $response ) + use ( $constraint, $p, $username ) + { + $this->assertInstanceOf( \User::class, $user ); + $this->assertSame( $username, $user->getName() ); + $this->assertSame( 'UTSysop', $creator->getName() ); + $this->assertInstanceOf( AuthenticationResponse::class, $response ); + $this->assertThat( $response->status, $constraint ); + $p->postCalled = $response->status; + } ); + } + + // We're testing with $wgNewUserLog = false, so assert that it worked + $dbw = wfGetDB( DB_MASTER ); + $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ); + + $first = true; + $created = false; + foreach ( $managerResponses as $i => $response ) { + $success = $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::PASS; + if ( $i === 'created' ) { + $created = true; + $this->hook( 'LocalUserCreated', $this->once() ) + ->with( + $this->callback( function ( $user ) use ( $username ) { + return $user->getName() === $username; + } ), + $this->equalTo( false ) + ); + $expectLog[] = [ LogLevel::INFO, "Creating user {user} during account creation" ]; + } else { + $this->hook( 'LocalUserCreated', $this->never() ); + } + + $ex = null; + try { + if ( $first ) { + $userReq = new UsernameAuthenticationRequest; + $userReq->username = $username; + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $req ], 'http://localhost/' + ); + } else { + $ret = $this->manager->continueAccountCreation( [ $req ] ); + } + if ( $response instanceof \Exception ) { + $this->fail( 'Expected exception not thrown', "Response $i" ); + } + } catch ( \Exception $ex ) { + if ( !$response instanceof \Exception ) { + throw $ex; + } + $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ), + "Response $i, exception, session state" + ); + $this->unhook( 'LocalUserCreated' ); + return; + } + + $this->unhook( 'LocalUserCreated' ); + + $this->assertSame( 'http://localhost/', $req->returnToUrl ); + + if ( $success ) { + $this->assertNotNull( $ret->loginRequest, "Response $i, login marker" ); + $this->assertContains( + $ret->loginRequest, $this->managerPriv->createdAccountAuthenticationRequests, + "Response $i, login marker" + ); + + $expectLog[] = [ + LogLevel::INFO, + "MediaWiki\Auth\AuthManager::continueAccountCreation: Account creation succeeded for {user}" + ]; + + // Set some fields in the expected $response that we couldn't + // know in provideAccountCreation(). + $response->username = $username; + $response->loginRequest = $ret->loginRequest; + } else { + $this->assertNull( $ret->loginRequest, "Response $i, login marker" ); + $this->assertSame( [], $this->managerPriv->createdAccountAuthenticationRequests, + "Response $i, login marker" ); + } + $ret->message = $this->message( $ret->message ); + $this->assertEquals( $response, $ret, "Response $i, response" ); + if ( $success || $response->status === AuthenticationResponse::FAIL ) { + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ), + "Response $i, session state" + ); + foreach ( $providers as $p ) { + $this->assertSame( $response->status, $p->postCalled, + "Response $i, post-auth callback called" ); + } + } else { + $this->assertNotNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ), + "Response $i, session state" + ); + foreach ( $ret->neededRequests as $neededReq ) { + $this->assertEquals( AuthManager::ACTION_CREATE, $neededReq->action, + "Response $i, neededRequest action" ); + } + $this->assertEquals( + $ret->neededRequests, + $this->manager->getAuthenticationRequests( AuthManager::ACTION_CREATE_CONTINUE ), + "Response $i, continuation check" + ); + foreach ( $providers as $p ) { + $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" ); + } + } + + if ( $created ) { + $this->assertNotEquals( 0, \User::idFromName( $username ) ); + } else { + $this->assertEquals( 0, \User::idFromName( $username ) ); + } + + $first = false; + } + + $this->assertSame( $expectLog, $this->logger->getBuffer() ); + + $this->assertSame( + $maxLogId, + $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ) + ); + } + + public function provideAccountCreation() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $good = StatusValue::newGood(); + + return [ + 'Pre-creation test fail in pre' => [ + StatusValue::newFatal( 'fail-from-pre' ), $good, $good, + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ), + ] + ], + 'Pre-creation test fail in primary' => [ + $good, StatusValue::newFatal( 'fail-from-primary' ), $good, + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ] + ], + 'Pre-creation test fail in secondary' => [ + $good, $good, StatusValue::newFatal( 'fail-from-secondary' ), + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-secondary' ) ), + ] + ], + 'Failure in primary' => [ + $good, $good, $good, + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ], + [], + $tmp + ], + 'All primary abstain' => [ + $good, $good, $good, + [ + AuthenticationResponse::newAbstain(), + ], + [], + [ + AuthenticationResponse::newFail( $this->message( 'authmanager-create-no-primary' ) ) + ] + ], + 'Primary UI, then redirect, then fail' => [ + $good, $good, $good, + $tmp = [ + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ), + AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ), + ], + [], + $tmp + ], + 'Primary redirect, then abstain' => [ + $good, $good, $good, + [ + $tmp = AuthenticationResponse::newRedirect( + [ $req ], '/foo.html', [ 'foo' => 'bar' ] + ), + AuthenticationResponse::newAbstain(), + ], + [], + [ + $tmp, + new \DomainException( + 'MockPrimaryAuthenticationProvider::continuePrimaryAccountCreation() returned ABSTAIN' + ) + ] + ], + 'Primary UI, then pass; secondary abstain' => [ + $good, $good, $good, + [ + $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass(), + ], + [ + AuthenticationResponse::newAbstain(), + ], + [ + $tmp1, + 'created' => AuthenticationResponse::newPass( '' ), + ] + ], + 'Primary pass; secondary UI then pass' => [ + $good, $good, $good, + [ + AuthenticationResponse::newPass( '' ), + ], + [ + $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass( '' ), + ], + [ + 'created' => $tmp1, + AuthenticationResponse::newPass( '' ), + ] + ], + 'Primary pass; secondary fail' => [ + $good, $good, $good, + [ + AuthenticationResponse::newPass(), + ], + [ + AuthenticationResponse::newFail( $this->message( '...' ) ), + ], + [ + 'created' => new \DomainException( + 'MockSecondaryAuthenticationProvider::beginSecondaryAccountCreation() returned FAIL. ' . + 'Secondary providers are not allowed to fail account creation, ' . + 'that should have been done via testForAccountCreation().' + ) + ] + ], + ]; + } + + /** + * @dataProvider provideAccountCreationLogging + * @param bool $isAnon + * @param string|null $logSubtype + */ + public function testAccountCreationLogging( $isAnon, $logSubtype ) { + $creator = $isAnon ? new \User : \User::newFromName( 'UTSysop' ); + $username = self::usernameForCreation(); + + $this->initializeManager(); + + // Set up lots of mocks... + $mock = $this->getMockForAbstractClass( + \MediaWiki\Auth\PrimaryAuthenticationProvider::class, [] + ); + $mock->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mock->expects( $this->any() )->method( 'testForAccountCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAccountCreation' ) + ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) ); + $mock->expects( $this->any() )->method( 'finishAccountCreation' ) + ->will( $this->returnValue( $logSubtype ) ); + + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->logger->setCollect( true ); + + $this->config->set( 'NewUserLog', true ); + + $dbw = wfGetDB( DB_MASTER ); + $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ); + + $userReq = new UsernameAuthenticationRequest; + $userReq->username = $username; + $reasonReq = new CreationReasonAuthenticationRequest; + $reasonReq->reason = $this->toString(); + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $reasonReq ], 'http://localhost/' + ); + + $this->assertSame( AuthenticationResponse::PASS, $ret->status ); + + $user = \User::newFromName( $username ); + $this->assertNotEquals( 0, $user->getId(), 'sanity check' ); + $this->assertNotEquals( $creator->getId(), $user->getId(), 'sanity check' ); + + $data = \DatabaseLogEntry::getSelectQueryData(); + $rows = iterator_to_array( $dbw->select( + $data['tables'], + $data['fields'], + [ + 'log_id > ' . (int)$maxLogId, + 'log_type' => 'newusers' + ] + $data['conds'], + __METHOD__, + $data['options'], + $data['join_conds'] + ) ); + $this->assertCount( 1, $rows ); + $entry = \DatabaseLogEntry::newFromRow( reset( $rows ) ); + + $this->assertSame( $logSubtype ?: ( $isAnon ? 'create' : 'create2' ), $entry->getSubtype() ); + $this->assertSame( + $isAnon ? $user->getId() : $creator->getId(), + $entry->getPerformer()->getId() + ); + $this->assertSame( + $isAnon ? $user->getName() : $creator->getName(), + $entry->getPerformer()->getName() + ); + $this->assertSame( $user->getUserPage()->getFullText(), $entry->getTarget()->getFullText() ); + $this->assertSame( [ '4::userid' => $user->getId() ], $entry->getParameters() ); + $this->assertSame( $this->toString(), $entry->getComment() ); + } + + public static function provideAccountCreationLogging() { + return [ + [ true, null ], + [ true, 'foobar' ], + [ false, null ], + [ false, 'byemail' ], + ]; + } + + public function testAutoAccountCreation() { + global $wgGroupPermissions, $wgHooks; + + // PHPUnit seems to have a bug where it will call the ->with() + // callbacks for our hooks again after the test is run (WTF?), which + // breaks here because $username no longer matches $user by the end of + // the testing. + $workaroundPHPUnitBug = false; + + $username = self::usernameForCreation(); + $this->initializeManager(); + + $this->stashMwGlobals( [ 'wgGroupPermissions' ] ); + $wgGroupPermissions['*']['createaccount'] = true; + $wgGroupPermissions['*']['autocreateaccount'] = false; + + \ObjectCache::$instances[__METHOD__] = new \HashBagOStuff(); + $this->setMwGlobals( [ 'wgMainCacheType' => __METHOD__ ] ); + + // Set up lots of mocks... + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + } + + $good = StatusValue::newGood(); + $callback = $this->callback( function ( $user ) use ( &$username, &$workaroundPHPUnitBug ) { + return $workaroundPHPUnitBug || $user->getName() === $username; + } ); + + $mocks['pre']->expects( $this->exactly( 12 ) )->method( 'testUserForCreation' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ) + ->will( $this->onConsecutiveCalls( + StatusValue::newFatal( 'ok' ), StatusValue::newFatal( 'ok' ), // For testing permissions + StatusValue::newFatal( 'fail-in-pre' ), $good, $good, + $good, // backoff test + $good, // addToDatabase fails test + $good, // addToDatabase throws test + $good, // addToDatabase exists test + $good, $good, $good // success + ) ); + + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mocks['primary']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( true ) ); + $mocks['primary']->expects( $this->exactly( 9 ) )->method( 'testUserForCreation' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ) + ->will( $this->onConsecutiveCalls( + StatusValue::newFatal( 'fail-in-primary' ), $good, + $good, // backoff test + $good, // addToDatabase fails test + $good, // addToDatabase throws test + $good, // addToDatabase exists test + $good, $good, $good + ) ); + $mocks['primary']->expects( $this->exactly( 3 ) )->method( 'autoCreatedAccount' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ); + + $mocks['secondary']->expects( $this->exactly( 8 ) )->method( 'testUserForCreation' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ) + ->will( $this->onConsecutiveCalls( + StatusValue::newFatal( 'fail-in-secondary' ), + $good, // backoff test + $good, // addToDatabase fails test + $good, // addToDatabase throws test + $good, // addToDatabase exists test + $good, $good, $good + ) ); + $mocks['secondary']->expects( $this->exactly( 3 ) )->method( 'autoCreatedAccount' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ); + + $this->preauthMocks = [ $mocks['pre'] ]; + $this->primaryauthMocks = [ $mocks['primary'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + $this->initializeManager( true ); + $session = $this->request->getSession(); + + $logger = new \TestLogger( true, function ( $m ) { + $m = str_replace( 'MediaWiki\\Auth\\AuthManager::autoCreateUser: ', '', $m ); + return $m; + } ); + $this->manager->setLogger( $logger ); + + try { + $user = \User::newFromName( 'UTSysop' ); + $this->manager->autoCreateUser( $user, 'InvalidSource', true ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Unknown auto-creation source: InvalidSource', $ex->getMessage() ); + } + + // First, check an existing user + $session->clear(); + $user = \User::newFromName( 'UTSysop' ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $expect = \Status::newGood(); + $expect->warning( 'userexists' ); + $this->assertEquals( $expect, $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSysop', $user->getName() ); + $this->assertEquals( $user->getId(), $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, '{username} already exists locally' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $session->clear(); + $user = \User::newFromName( 'UTSysop' ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false ); + $this->unhook( 'LocalUserCreated' ); + $expect = \Status::newGood(); + $expect->warning( 'userexists' ); + $this->assertEquals( $expect, $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSysop', $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, '{username} already exists locally' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + // Wiki is read-only + $session->clear(); + $readOnlyMode = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode(); + $readOnlyMode->setReason( 'Because' ); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( wfMessage( 'readonlytext', 'Because' ) ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'denied by wfReadOnly(): {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $readOnlyMode->setReason( false ); + + // Session blacklisted + $session->clear(); + $session->set( 'AuthManager::AutoCreateBlacklist', 'test' ); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'test' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'blacklisted in session {sessionid}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $session->clear(); + $session->set( 'AuthManager::AutoCreateBlacklist', StatusValue::newFatal( 'test2' ) ); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'test2' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'blacklisted in session {sessionid}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + // Uncreatable name + $session->clear(); + $user = \User::newFromName( $username . '@' ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'noname' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username . '@', $user->getId() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'name "{username}" is not creatable' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( 'noname', $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + + // IP unable to create accounts + $wgGroupPermissions['*']['createaccount'] = false; + $wgGroupPermissions['*']['autocreateaccount'] = false; + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'authmanager-autocreate-noperm' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'IP lacks the ability to create or autocreate accounts' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( + 'authmanager-autocreate-noperm', $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + // Test that both permutations of permissions are allowed + // (this hits the two "ok" entries in $mocks['pre']) + $wgGroupPermissions['*']['createaccount'] = false; + $wgGroupPermissions['*']['autocreateaccount'] = true; + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'ok' ), $ret ); + + $wgGroupPermissions['*']['createaccount'] = true; + $wgGroupPermissions['*']['autocreateaccount'] = false; + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'ok' ), $ret ); + $logger->clearBuffer(); + + // Test lock fail + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $cache = \ObjectCache::getLocalClusterInstance(); + $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + unset( $lock ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'usernameinprogress' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Could not acquire account creation lock' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test pre-authentication provider fail + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'fail-in-pre' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertEquals( + StatusValue::newFatal( 'fail-in-pre' ), $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'fail-in-primary' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertEquals( + StatusValue::newFatal( 'fail-in-primary' ), $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'fail-in-secondary' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertEquals( + StatusValue::newFatal( 'fail-in-secondary' ), $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + // Test backoff + $cache = \ObjectCache::getLocalClusterInstance(); + $backoffKey = wfMemcKey( 'AuthManager', 'autocreate-failed', md5( $username ) ); + $cache->set( $backoffKey, true ); + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'authmanager-autocreate-exception' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, '{username} denied by prior creation attempt failures' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + $cache->delete( $backoffKey ); + + // Test addToDatabase fails + $session->clear(); + $user = $this->getMockBuilder( \User::class ) + ->setMethods( [ 'addToDatabase' ] )->getMock(); + $user->expects( $this->once() )->method( 'addToDatabase' ) + ->will( $this->returnValue( \Status::newFatal( 'because' ) ) ); + $user->setName( $username ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->assertEquals( \Status::newFatal( 'because' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + [ LogLevel::ERROR, '{username} failed with message {msg}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + + // Test addToDatabase throws an exception + $cache = \ObjectCache::getLocalClusterInstance(); + $backoffKey = wfMemcKey( 'AuthManager', 'autocreate-failed', md5( $username ) ); + $this->assertFalse( $cache->get( $backoffKey ), 'sanity check' ); + $session->clear(); + $user = $this->getMockBuilder( \User::class ) + ->setMethods( [ 'addToDatabase' ] )->getMock(); + $user->expects( $this->once() )->method( 'addToDatabase' ) + ->will( $this->throwException( new \Exception( 'Excepted' ) ) ); + $user->setName( $username ); + try { + $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \Exception $ex ) { + $this->assertSame( 'Excepted', $ex->getMessage() ); + } + $this->assertEquals( 0, $user->getId() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + [ LogLevel::ERROR, '{username} failed with exception {exception}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + $this->assertNotEquals( false, $cache->get( $backoffKey ) ); + $cache->delete( $backoffKey ); + + // Test addToDatabase fails because the user already exists. + $session->clear(); + $user = $this->getMockBuilder( \User::class ) + ->setMethods( [ 'addToDatabase' ] )->getMock(); + $user->expects( $this->once() )->method( 'addToDatabase' ) + ->will( $this->returnCallback( function () use ( $username, &$user ) { + $oldUser = \User::newFromName( $username ); + $status = $oldUser->addToDatabase(); + $this->assertTrue( $status->isOK(), 'sanity check' ); + $user->setId( $oldUser->getId() ); + return \Status::newFatal( 'userexists' ); + } ) ); + $user->setName( $username ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $expect = \Status::newGood(); + $expect->warning( 'userexists' ); + $this->assertEquals( $expect, $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertEquals( $username, $user->getName() ); + $this->assertEquals( $user->getId(), $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + [ LogLevel::INFO, '{username} already exists locally (race)' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + + // Success! + $session->clear(); + $username = self::usernameForCreation(); + $user = \User::newFromName( $username ); + $this->hook( 'AuthPluginAutoCreate', $this->once() ) + ->with( $callback ); + $this->hideDeprecated( 'AuthPluginAutoCreate hook (used in ' . + get_class( $wgHooks['AuthPluginAutoCreate'][0] ) . '::onAuthPluginAutoCreate)' ); + $this->hook( 'LocalUserCreated', $this->once() ) + ->with( $callback, $this->equalTo( true ) ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'AuthPluginAutoCreate' ); + $this->assertEquals( \Status::newGood(), $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertEquals( $username, $user->getName() ); + $this->assertEquals( $user->getId(), $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $dbw = wfGetDB( DB_MASTER ); + $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ); + $session->clear(); + $username = self::usernameForCreation(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->once() ) + ->with( $callback, $this->equalTo( true ) ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newGood(), $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( + $maxLogId, + $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ) + ); + + $this->config->set( 'NewUserLog', true ); + $session->clear(); + $username = self::usernameForCreation(); + $user = \User::newFromName( $username ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false ); + $this->assertEquals( \Status::newGood(), $ret ); + $logger->clearBuffer(); + + $data = \DatabaseLogEntry::getSelectQueryData(); + $rows = iterator_to_array( $dbw->select( + $data['tables'], + $data['fields'], + [ + 'log_id > ' . (int)$maxLogId, + 'log_type' => 'newusers' + ] + $data['conds'], + __METHOD__, + $data['options'], + $data['join_conds'] + ) ); + $this->assertCount( 1, $rows ); + $entry = \DatabaseLogEntry::newFromRow( reset( $rows ) ); + + $this->assertSame( 'autocreate', $entry->getSubtype() ); + $this->assertSame( $user->getId(), $entry->getPerformer()->getId() ); + $this->assertSame( $user->getName(), $entry->getPerformer()->getName() ); + $this->assertSame( $user->getUserPage()->getFullText(), $entry->getTarget()->getFullText() ); + $this->assertSame( [ '4::userid' => $user->getId() ], $entry->getParameters() ); + + $workaroundPHPUnitBug = true; + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $expect + * @param array $state + */ + public function testGetAuthenticationRequests( $action, $expect, $state = [] ) { + $makeReq = function ( $key ) use ( $action ) { + $req = $this->createMock( AuthenticationRequest::class ); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $req->action = $action === AuthManager::ACTION_UNLINK ? AuthManager::ACTION_REMOVE : $action; + $req->key = $key; + return $req; + }; + $cmpReqs = function ( $a, $b ) { + $ret = strcmp( get_class( $a ), get_class( $b ) ); + if ( !$ret ) { + $ret = strcmp( $a->key, $b->key ); + } + return $ret; + }; + + $good = StatusValue::newGood(); + + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockBuilder( "MediaWiki\\Auth\\$class" ) + ->setMethods( [ + 'getUniqueId', 'getAuthenticationRequests', 'providerAllowsAuthenticationDataChange', + ] ) + ->getMockForAbstractClass(); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key]->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $key, $makeReq ) { + return [ $makeReq( "$key-$action" ), $makeReq( 'generic' ) ]; + } ) ); + $mocks[$key]->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnValue( $good ) ); + } + + $primaries = []; + foreach ( [ + PrimaryAuthenticationProvider::TYPE_NONE, + PrimaryAuthenticationProvider::TYPE_CREATE, + PrimaryAuthenticationProvider::TYPE_LINK + ] as $type ) { + $class = 'PrimaryAuthenticationProvider'; + $mocks["primary-$type"] = $this->getMockBuilder( "MediaWiki\\Auth\\$class" ) + ->setMethods( [ + 'getUniqueId', 'accountCreationType', 'getAuthenticationRequests', + 'providerAllowsAuthenticationDataChange', + ] ) + ->getMockForAbstractClass(); + $mocks["primary-$type"]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "primary-$type" ) ); + $mocks["primary-$type"]->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + $mocks["primary-$type"]->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $type, $makeReq ) { + return [ $makeReq( "primary-$type-$action" ), $makeReq( 'generic' ) ]; + } ) ); + $mocks["primary-$type"]->expects( $this->any() ) + ->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnValue( $good ) ); + $this->primaryauthMocks[] = $mocks["primary-$type"]; + } + + $mocks['primary2'] = $this->getMockBuilder( PrimaryAuthenticationProvider::class ) + ->setMethods( [ + 'getUniqueId', 'accountCreationType', 'getAuthenticationRequests', + 'providerAllowsAuthenticationDataChange', + ] ) + ->getMockForAbstractClass(); + $mocks['primary2']->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mocks['primary2']->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnValue( [] ) ); + $mocks['primary2']->expects( $this->any() ) + ->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) use ( $good ) { + return $req->key === 'generic' ? StatusValue::newFatal( 'no' ) : $good; + } ) ); + $this->primaryauthMocks[] = $mocks['primary2']; + + $this->preauthMocks = [ $mocks['pre'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + $this->initializeManager( true ); + + if ( $state ) { + if ( isset( $state['continueRequests'] ) ) { + $state['continueRequests'] = array_map( $makeReq, $state['continueRequests'] ); + } + if ( $action === AuthManager::ACTION_LOGIN_CONTINUE ) { + $this->request->getSession()->setSecret( 'AuthManager::authnState', $state ); + } elseif ( $action === AuthManager::ACTION_CREATE_CONTINUE ) { + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', $state ); + } elseif ( $action === AuthManager::ACTION_LINK_CONTINUE ) { + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', $state ); + } + } + + $expectReqs = array_map( $makeReq, $expect ); + if ( $action === AuthManager::ACTION_LOGIN ) { + $req = new RememberMeAuthenticationRequest; + $req->action = $action; + $req->required = AuthenticationRequest::REQUIRED; + $expectReqs[] = $req; + } elseif ( $action === AuthManager::ACTION_CREATE ) { + $req = new UsernameAuthenticationRequest; + $req->action = $action; + $expectReqs[] = $req; + $req = new UserDataAuthenticationRequest; + $req->action = $action; + $req->required = AuthenticationRequest::REQUIRED; + $expectReqs[] = $req; + } + usort( $expectReqs, $cmpReqs ); + + $actual = $this->manager->getAuthenticationRequests( $action ); + foreach ( $actual as $req ) { + // Don't test this here. + $req->required = AuthenticationRequest::REQUIRED; + } + usort( $actual, $cmpReqs ); + + $this->assertEquals( $expectReqs, $actual ); + + // Test CreationReasonAuthenticationRequest gets returned + if ( $action === AuthManager::ACTION_CREATE ) { + $req = new CreationReasonAuthenticationRequest; + $req->action = $action; + $req->required = AuthenticationRequest::REQUIRED; + $expectReqs[] = $req; + usort( $expectReqs, $cmpReqs ); + + $actual = $this->manager->getAuthenticationRequests( $action, \User::newFromName( 'UTSysop' ) ); + foreach ( $actual as $req ) { + // Don't test this here. + $req->required = AuthenticationRequest::REQUIRED; + } + usort( $actual, $cmpReqs ); + + $this->assertEquals( $expectReqs, $actual ); + } + } + + public static function provideGetAuthenticationRequests() { + return [ + [ + AuthManager::ACTION_LOGIN, + [ 'pre-login', 'primary-none-login', 'primary-create-login', + 'primary-link-login', 'secondary-login', 'generic' ], + ], + [ + AuthManager::ACTION_CREATE, + [ 'pre-create', 'primary-none-create', 'primary-create-create', + 'primary-link-create', 'secondary-create', 'generic' ], + ], + [ + AuthManager::ACTION_LINK, + [ 'primary-link-link', 'generic' ], + ], + [ + AuthManager::ACTION_CHANGE, + [ 'primary-none-change', 'primary-create-change', 'primary-link-change', + 'secondary-change' ], + ], + [ + AuthManager::ACTION_REMOVE, + [ 'primary-none-remove', 'primary-create-remove', 'primary-link-remove', + 'secondary-remove' ], + ], + [ + AuthManager::ACTION_UNLINK, + [ 'primary-link-remove' ], + ], + [ + AuthManager::ACTION_LOGIN_CONTINUE, + [], + ], + [ + AuthManager::ACTION_LOGIN_CONTINUE, + $reqs = [ 'continue-login', 'foo', 'bar' ], + [ + 'continueRequests' => $reqs, + ], + ], + [ + AuthManager::ACTION_CREATE_CONTINUE, + [], + ], + [ + AuthManager::ACTION_CREATE_CONTINUE, + $reqs = [ 'continue-create', 'foo', 'bar' ], + [ + 'continueRequests' => $reqs, + ], + ], + [ + AuthManager::ACTION_LINK_CONTINUE, + [], + ], + [ + AuthManager::ACTION_LINK_CONTINUE, + $reqs = [ 'continue-link', 'foo', 'bar' ], + [ + 'continueRequests' => $reqs, + ], + ], + ]; + } + + public function testGetAuthenticationRequestsRequired() { + $makeReq = function ( $key, $required ) { + $req = $this->createMock( AuthenticationRequest::class ); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $req->action = AuthManager::ACTION_LOGIN; + $req->key = $key; + $req->required = $required; + return $req; + }; + $cmpReqs = function ( $a, $b ) { + $ret = strcmp( get_class( $a ), get_class( $b ) ); + if ( !$ret ) { + $ret = strcmp( $a->key, $b->key ); + } + return $ret; + }; + + $good = StatusValue::newGood(); + + $primary1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $primary1->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary1' ) ); + $primary1->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $primary1->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) { + return [ + $makeReq( "primary-shared", AuthenticationRequest::REQUIRED ), + $makeReq( "required", AuthenticationRequest::REQUIRED ), + $makeReq( "optional", AuthenticationRequest::OPTIONAL ), + $makeReq( "foo", AuthenticationRequest::REQUIRED ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::OPTIONAL ), + ]; + } ) ); + + $primary2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $primary2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $primary2->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $primary2->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) { + return [ + $makeReq( "primary-shared", AuthenticationRequest::REQUIRED ), + $makeReq( "required2", AuthenticationRequest::REQUIRED ), + $makeReq( "optional2", AuthenticationRequest::OPTIONAL ), + ]; + } ) ); + + $secondary = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ); + $secondary->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'secondary' ) ); + $secondary->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) { + return [ + $makeReq( "foo", AuthenticationRequest::OPTIONAL ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::REQUIRED ), + ]; + } ) ); + + $rememberReq = new RememberMeAuthenticationRequest; + $rememberReq->action = AuthManager::ACTION_LOGIN; + + $this->primaryauthMocks = [ $primary1, $primary2 ]; + $this->secondaryauthMocks = [ $secondary ]; + $this->initializeManager( true ); + + $actual = $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN ); + $expected = [ + $rememberReq, + $makeReq( "primary-shared", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "required", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "required2", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "optional", AuthenticationRequest::OPTIONAL ), + $makeReq( "optional2", AuthenticationRequest::OPTIONAL ), + $makeReq( "foo", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::REQUIRED ), + ]; + usort( $actual, $cmpReqs ); + usort( $expected, $cmpReqs ); + $this->assertEquals( $expected, $actual ); + + $this->primaryauthMocks = [ $primary1 ]; + $this->secondaryauthMocks = [ $secondary ]; + $this->initializeManager( true ); + + $actual = $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN ); + $expected = [ + $rememberReq, + $makeReq( "primary-shared", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "required", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "optional", AuthenticationRequest::OPTIONAL ), + $makeReq( "foo", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::REQUIRED ), + ]; + usort( $actual, $cmpReqs ); + usort( $expected, $cmpReqs ); + $this->assertEquals( $expected, $actual ); + } + + public function testAllowsPropertyChange() { + $mocks = []; + foreach ( [ 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key]->expects( $this->any() )->method( 'providerAllowsPropertyChange' ) + ->will( $this->returnCallback( function ( $prop ) use ( $key ) { + return $prop !== $key; + } ) ); + } + + $this->primaryauthMocks = [ $mocks['primary'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + $this->initializeManager( true ); + + $this->assertTrue( $this->manager->allowsPropertyChange( 'foo' ) ); + $this->assertFalse( $this->manager->allowsPropertyChange( 'primary' ) ); + $this->assertFalse( $this->manager->allowsPropertyChange( 'secondary' ) ); + } + + public function testAutoCreateOnLogin() { + $username = self::usernameForCreation(); + + $req = $this->createMock( AuthenticationRequest::class ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + + $mock2 = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'secondary' ) ); + $mock2->expects( $this->any() )->method( 'beginSecondaryAuthentication' )->will( + $this->returnValue( + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ) + ) + ); + $mock2->expects( $this->any() )->method( 'continueSecondaryAuthentication' ) + ->will( $this->returnValue( AuthenticationResponse::newAbstain() ) ); + $mock2->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + + $this->primaryauthMocks = [ $mock ]; + $this->secondaryauthMocks = [ $mock2 ]; + $this->initializeManager( true ); + $this->manager->setLogger( new \Psr\Log\NullLogger() ); + $session = $this->request->getSession(); + $session->clear(); + + $this->assertSame( 0, \User::newFromName( $username )->getId(), + 'sanity check' ); + + $callback = $this->callback( function ( $user ) use ( $username ) { + return $user->getName() === $username; + } ); + + $this->hook( 'UserLoggedIn', $this->never() ); + $this->hook( 'LocalUserCreated', $this->once() )->with( $callback, $this->equalTo( true ) ); + $ret = $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'UserLoggedIn' ); + $this->assertSame( AuthenticationResponse::UI, $ret->status ); + + $id = (int)\User::newFromName( $username )->getId(); + $this->assertNotSame( 0, \User::newFromName( $username )->getId() ); + $this->assertSame( 0, $session->getUser()->getId() ); + + $this->hook( 'UserLoggedIn', $this->once() )->with( $callback ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAuthentication( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'UserLoggedIn' ); + $this->assertSame( AuthenticationResponse::PASS, $ret->status ); + $this->assertSame( $username, $ret->username ); + $this->assertSame( $id, $session->getUser()->getId() ); + } + + public function testAutoCreateFailOnLogin() { + $username = self::usernameForCreation(); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail-from-primary' ) ) ); + + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->manager->setLogger( new \Psr\Log\NullLogger() ); + $session = $this->request->getSession(); + $session->clear(); + + $this->assertSame( 0, $session->getUser()->getId(), + 'sanity check' ); + $this->assertSame( 0, \User::newFromName( $username )->getId(), + 'sanity check' ); + + $this->hook( 'UserLoggedIn', $this->never() ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'UserLoggedIn' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-authn-autocreate-failed', $ret->message->getKey() ); + + $this->assertSame( 0, \User::newFromName( $username )->getId() ); + $this->assertSame( 0, $session->getUser()->getId() ); + } + + public function testAuthenticationSessionData() { + $this->initializeManager( true ); + + $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->manager->setAuthenticationSessionData( 'foo', 'foo!' ); + $this->manager->setAuthenticationSessionData( 'bar', 'bar!' ); + $this->assertSame( 'foo!', $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->assertSame( 'bar!', $this->manager->getAuthenticationSessionData( 'bar' ) ); + $this->manager->removeAuthenticationSessionData( 'foo' ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->assertSame( 'bar!', $this->manager->getAuthenticationSessionData( 'bar' ) ); + $this->manager->removeAuthenticationSessionData( 'bar' ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'bar' ) ); + + $this->manager->setAuthenticationSessionData( 'foo', 'foo!' ); + $this->manager->setAuthenticationSessionData( 'bar', 'bar!' ); + $this->manager->removeAuthenticationSessionData( null ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'bar' ) ); + } + + public function testCanLinkAccounts() { + $types = [ + PrimaryAuthenticationProvider::TYPE_CREATE => true, + PrimaryAuthenticationProvider::TYPE_LINK => true, + PrimaryAuthenticationProvider::TYPE_NONE => false, + ]; + + foreach ( $types as $type => $can ) { + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $type ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->assertSame( $can, $this->manager->canCreateAccounts(), $type ); + } + } + + public function testBeginAccountLink() { + $user = \User::newFromName( 'UTSysop' ); + $this->initializeManager(); + + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', 'test' ); + try { + $this->manager->beginAccountLink( $user, [], 'http://localhost/' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account linking is not possible', $ex->getMessage() ); + } + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $ret = $this->manager->beginAccountLink( new \User, [], 'http://localhost/' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $ret = $this->manager->beginAccountLink( + \User::newFromName( 'UTDoesNotExist' ), [], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-userdoesnotexist', $ret->message->getKey() ); + } + + public function testContinueAccountLink() { + $user = \User::newFromName( 'UTSysop' ); + $this->initializeManager(); + + $session = [ + 'userid' => $user->getId(), + 'username' => $user->getName(), + 'primary' => 'X', + ]; + + try { + $this->manager->continueAccountLink( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account linking is not possible', $ex->getMessage() ); + } + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAccountLink' )->will( + $this->returnValue( AuthenticationResponse::newFail( $this->message( 'fail' ) ) ) + ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', null ); + $ret = $this->manager->continueAccountLink( [] ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-link-not-in-progress', $ret->message->getKey() ); + + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', + [ 'username' => $user->getName() . '<>' ] + $session ); + $ret = $this->manager->continueAccountLink( [] ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) ); + + $id = $user->getId(); + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', + [ 'userid' => $id + 1 ] + $session ); + try { + $ret = $this->manager->continueAccountLink( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertEquals( + "User \"{$user->getName()}\" is valid, but ID $id != " . ( $id + 1 ) . '!', + $ex->getMessage() + ); + } + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) ); + } + + /** + * @dataProvider provideAccountLink + * @param StatusValue $preTest + * @param array $primaryResponses + * @param array $managerResponses + */ + public function testAccountLink( + StatusValue $preTest, array $primaryResponses, array $managerResponses + ) { + $user = \User::newFromName( 'UTSysop' ); + + $this->initializeManager(); + + // Set up lots of mocks... + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->primary = $primaryResponses; + $mocks = []; + + foreach ( [ 'pre', 'primary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + + for ( $i = 2; $i <= 3; $i++ ) { + $mocks[$key . $i] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key . $i]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . $i ) ); + } + } + + $mocks['pre']->expects( $this->any() )->method( 'testForAccountLink' ) + ->will( $this->returnCallback( + function ( $u ) + use ( $user, $preTest ) + { + $this->assertSame( $user->getId(), $u->getId() ); + $this->assertSame( $user->getName(), $u->getName() ); + return $preTest; + } + ) ); + + $mocks['pre2']->expects( $this->atMost( 1 ) )->method( 'testForAccountLink' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $ct = count( $req->primary ); + $callback = $this->returnCallback( function ( $u, $reqs ) use ( $user, $req ) { + $this->assertSame( $user->getId(), $u->getId() ); + $this->assertSame( $user->getName(), $u->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $user->getName(), $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + return array_shift( $req->primary ); + } ); + $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginPrimaryAccountLink' ) + ->will( $callback ); + $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continuePrimaryAccountLink' ) + ->will( $callback ); + + $abstain = AuthenticationResponse::newAbstain(); + $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAccountLink' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAccountLink' ); + $mocks['primary3']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mocks['primary3']->expects( $this->never() )->method( 'beginPrimaryAccountLink' ); + $mocks['primary3']->expects( $this->never() )->method( 'continuePrimaryAccountLink' ); + + $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ]; + $this->primaryauthMocks = [ $mocks['primary3'], $mocks['primary2'], $mocks['primary'] ]; + $this->logger = new \TestLogger( true, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $this->initializeManager( true ); + + $constraint = \PHPUnit_Framework_Assert::logicalOr( + $this->equalTo( AuthenticationResponse::PASS ), + $this->equalTo( AuthenticationResponse::FAIL ) + ); + $providers = array_merge( $this->preauthMocks, $this->primaryauthMocks ); + foreach ( $providers as $p ) { + $p->postCalled = false; + $p->expects( $this->atMost( 1 ) )->method( 'postAccountLink' ) + ->willReturnCallback( function ( $user, $response ) use ( $constraint, $p ) { + $this->assertInstanceOf( \User::class, $user ); + $this->assertSame( 'UTSysop', $user->getName() ); + $this->assertInstanceOf( AuthenticationResponse::class, $response ); + $this->assertThat( $response->status, $constraint ); + $p->postCalled = $response->status; + } ); + } + + $first = true; + $created = false; + $expectLog = []; + foreach ( $managerResponses as $i => $response ) { + if ( $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::PASS + ) { + $expectLog[] = [ LogLevel::INFO, 'Account linked to {user} by primary' ]; + } + + $ex = null; + try { + if ( $first ) { + $ret = $this->manager->beginAccountLink( $user, [ $req ], 'http://localhost/' ); + } else { + $ret = $this->manager->continueAccountLink( [ $req ] ); + } + if ( $response instanceof \Exception ) { + $this->fail( 'Expected exception not thrown', "Response $i" ); + } + } catch ( \Exception $ex ) { + if ( !$response instanceof \Exception ) { + throw $ex; + } + $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ), + "Response $i, exception, session state" ); + return; + } + + $this->assertSame( 'http://localhost/', $req->returnToUrl ); + + $ret->message = $this->message( $ret->message ); + $this->assertEquals( $response, $ret, "Response $i, response" ); + if ( $response->status === AuthenticationResponse::PASS || + $response->status === AuthenticationResponse::FAIL + ) { + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ), + "Response $i, session state" ); + foreach ( $providers as $p ) { + $this->assertSame( $response->status, $p->postCalled, + "Response $i, post-auth callback called" ); + } + } else { + $this->assertNotNull( + $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ), + "Response $i, session state" + ); + foreach ( $ret->neededRequests as $neededReq ) { + $this->assertEquals( AuthManager::ACTION_LINK, $neededReq->action, + "Response $i, neededRequest action" ); + } + $this->assertEquals( + $ret->neededRequests, + $this->manager->getAuthenticationRequests( AuthManager::ACTION_LINK_CONTINUE ), + "Response $i, continuation check" + ); + foreach ( $providers as $p ) { + $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" ); + } + } + + $first = false; + } + + $this->assertSame( $expectLog, $this->logger->getBuffer() ); + } + + public function provideAccountLink() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $good = StatusValue::newGood(); + + return [ + 'Pre-link test fail in pre' => [ + StatusValue::newFatal( 'fail-from-pre' ), + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ), + ] + ], + 'Failure in primary' => [ + $good, + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ], + $tmp + ], + 'All primary abstain' => [ + $good, + [ + AuthenticationResponse::newAbstain(), + ], + [ + AuthenticationResponse::newFail( $this->message( 'authmanager-link-no-primary' ) ) + ] + ], + 'Primary UI, then redirect, then fail' => [ + $good, + $tmp = [ + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ), + AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ), + ], + $tmp + ], + 'Primary redirect, then abstain' => [ + $good, + [ + $tmp = AuthenticationResponse::newRedirect( + [ $req ], '/foo.html', [ 'foo' => 'bar' ] + ), + AuthenticationResponse::newAbstain(), + ], + [ + $tmp, + new \DomainException( + 'MockPrimaryAuthenticationProvider::continuePrimaryAccountLink() returned ABSTAIN' + ) + ] + ], + 'Primary UI, then pass' => [ + $good, + [ + $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass(), + ], + [ + $tmp1, + AuthenticationResponse::newPass( '' ), + ] + ], + 'Primary pass' => [ + $good, + [ + AuthenticationResponse::newPass( '' ), + ], + [ + AuthenticationResponse::newPass( '' ), + ] + ], + ]; + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php new file mode 100644 index 00000000..57c3e7eb --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php @@ -0,0 +1,716 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\AuthPluginPrimaryAuthenticationProvider + */ +class AuthPluginPrimaryAuthenticationProviderTest extends \MediaWikiTestCase { + public function testConstruction() { + $plugin = new AuthManagerAuthPlugin(); + try { + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'Trying to wrap AuthManagerAuthPlugin in AuthPluginPrimaryAuthenticationProvider ' . + 'makes no sense.', + $ex->getMessage() + ); + } + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + [ new PasswordAuthenticationRequest ], + $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ) + ); + + $req = $this->createMock( PasswordAuthenticationRequest::class ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin, get_class( $req ) ); + $this->assertEquals( + [ $req ], + $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ) + ); + + $reqType = get_class( $this->createMock( AuthenticationRequest::class ) ); + try { + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin, $reqType ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + "$reqType is not a MediaWiki\\Auth\\PasswordAuthenticationRequest", + $ex->getMessage() + ); + } + } + + public function testOnUserSaveSettings() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'updateExternalDB' ) + ->with( $this->identicalTo( $user ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + \Hooks::run( 'UserSaveSettings', [ $user ] ); + } + + public function testOnUserGroupsChanged() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'updateExternalDBGroups' ) + ->with( + $this->identicalTo( $user ), + $this->identicalTo( [ 'added' ] ), + $this->identicalTo( [ 'removed' ] ) + ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + \Hooks::run( 'UserGroupsChanged', [ $user, [ 'added' ], [ 'removed' ], false, false, [], [] ] ); + } + + public function testOnUserLoggedIn() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->exactly( 2 ) )->method( 'updateUser' ) + ->with( $this->identicalTo( $user ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + \Hooks::run( 'UserLoggedIn', [ $user ] ); + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'updateUser' ) + ->will( $this->returnCallback( function ( &$user ) { + $user = \User::newFromName( 'UTSysop' ); + } ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + \Hooks::run( 'UserLoggedIn', [ $user ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + get_class( $plugin ) . '::updateUser() tried to replace $user!', + $ex->getMessage() + ); + } + } + + public function testOnLocalUserCreated() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->exactly( 2 ) )->method( 'initUser' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + \Hooks::run( 'LocalUserCreated', [ $user, false ] ); + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'initUser' ) + ->will( $this->returnCallback( function ( &$user ) { + $user = \User::newFromName( 'UTSysop' ); + } ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + \Hooks::run( 'LocalUserCreated', [ $user, false ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + get_class( $plugin ) . '::initUser() tried to replace $user!', + $ex->getMessage() + ); + } + } + + public function testGetUniqueId() { + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertSame( + 'MediaWiki\\Auth\\AuthPluginPrimaryAuthenticationProvider:' . get_class( $plugin ), + $provider->getUniqueId() + ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + * @param bool $allowPasswordChange + */ + public function testGetAuthenticationRequests( $action, $response, $allowPasswordChange ) { + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'allowPasswordChange' ) + ->will( $this->returnValue( $allowPasswordChange ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + $arr = [ new PasswordAuthenticationRequest() ]; + return [ + [ AuthManager::ACTION_LOGIN, $arr, true ], + [ AuthManager::ACTION_LOGIN, $arr, false ], + [ AuthManager::ACTION_CREATE, $arr, true ], + [ AuthManager::ACTION_CREATE, $arr, false ], + [ AuthManager::ACTION_LINK, [], true ], + [ AuthManager::ACTION_LINK, [], false ], + [ AuthManager::ACTION_CHANGE, $arr, true ], + [ AuthManager::ACTION_CHANGE, [], false ], + [ AuthManager::ACTION_REMOVE, $arr, true ], + [ AuthManager::ACTION_REMOVE, [], false ], + ]; + } + + public function testAuthentication() { + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $plugin = $this->getMockBuilder( \AuthPlugin::class ) + ->setMethods( [ 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->never() )->method( 'authenticate' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $plugin = $this->getMockBuilder( \AuthPlugin::class ) + ->setMethods( [ 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newPass( 'Foo', $req ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $plugin = $this->getMockBuilder( \AuthPlugin::class ) + ->setMethods( [ 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->never() )->method( 'authenticate' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $pluginUser = $this->getMockBuilder( \AuthPluginUser::class ) + ->setMethods( [ 'isLocked' ] ) + ->disableOriginalConstructor() + ->getMock(); + $pluginUser->expects( $this->once() )->method( 'isLocked' ) + ->will( $this->returnValue( true ) ); + $plugin = $this->getMockBuilder( \AuthPlugin::class ) + ->setMethods( [ 'userExists', 'getUserInstance', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'getUserInstance' ) + ->will( $this->returnValue( $pluginUser ) ); + $plugin->expects( $this->never() )->method( 'authenticate' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $plugin = $this->getMockBuilder( \AuthPlugin::class ) + ->setMethods( [ 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $plugin = $this->getMockBuilder( \AuthPlugin::class ) + ->setMethods( [ 'userExists', 'authenticate', 'strict' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->any() )->method( 'strict' )->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpassword', $ret->message->getKey() ); + + $plugin = $this->getMockBuilder( \AuthPlugin::class ) + ->setMethods( [ 'userExists', 'authenticate', 'strictUserAuth' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->any() )->method( 'strictUserAuth' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpassword', $ret->message->getKey() ); + + $plugin = $this->getMockBuilder( \AuthPlugin::class ) + ->setMethods( [ 'domainList', 'validDomain', 'setDomain', 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->any() )->method( 'domainList' ) + ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) ); + $plugin->expects( $this->any() )->method( 'validDomain' ) + ->will( $this->returnCallback( function ( $domain ) { + return in_array( $domain, [ 'Domain1', 'Domain2' ] ); + } ) ); + $plugin->expects( $this->once() )->method( 'setDomain' ) + ->with( $this->equalTo( 'Domain2' ) ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ); + $req->username = 'foo'; + $req->password = 'bar'; + $req->domain = 'Domain2'; + $provider->beginPrimaryAuthentication( [ $req ] ); + } + + public function testTestUserExists() { + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertTrue( $provider->testUserExists( 'foo' ) ); + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertFalse( $provider->testUserExists( 'foo' ) ); + } + + public function testTestUserCanAuthenticate() { + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->never() )->method( 'getUserInstance' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertFalse( $provider->testUserCanAuthenticate( 'foo' ) ); + + $pluginUser = $this->getMockBuilder( \AuthPluginUser::class ) + ->disableOriginalConstructor() + ->getMock(); + $pluginUser->expects( $this->once() )->method( 'isLocked' ) + ->will( $this->returnValue( true ) ); + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'getUserInstance' ) + ->with( $this->callback( function ( $user ) { + $this->assertInstanceOf( \User::class, $user ); + $this->assertEquals( 'Foo', $user->getName() ); + return true; + } ) ) + ->will( $this->returnValue( $pluginUser ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertFalse( $provider->testUserCanAuthenticate( 'foo' ) ); + + $pluginUser = $this->getMockBuilder( \AuthPluginUser::class ) + ->disableOriginalConstructor() + ->getMock(); + $pluginUser->expects( $this->once() )->method( 'isLocked' ) + ->will( $this->returnValue( false ) ); + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'getUserInstance' ) + ->with( $this->callback( function ( $user ) { + $this->assertInstanceOf( \User::class, $user ); + $this->assertEquals( 'Foo', $user->getName() ); + return true; + } ) ) + ->will( $this->returnValue( $pluginUser ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertTrue( $provider->testUserCanAuthenticate( 'foo' ) ); + } + + public function testProviderRevokeAccessForUser() { + $plugin = $this->getMockBuilder( \AuthPlugin::class ) + ->setMethods( [ 'userExists', 'setPassword' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' )->willReturn( true ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->identicalTo( null ) ) + ->willReturn( true ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $provider->providerRevokeAccessForUser( 'foo' ); + + $plugin = $this->getMockBuilder( \AuthPlugin::class ) + ->setMethods( [ 'domainList', 'userExists', 'setPassword' ] ) + ->getMock(); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [ 'D1', 'D2', 'D3' ] ); + $plugin->expects( $this->exactly( 3 ) )->method( 'userExists' ) + ->willReturnCallback( function () use ( $plugin ) { + return $plugin->getDomain() !== 'D2'; + } ); + $plugin->expects( $this->exactly( 2 ) )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->identicalTo( null ) ) + ->willReturnCallback( function () use ( $plugin ) { + $this->assertNotEquals( 'D2', $plugin->getDomain() ); + return $plugin->getDomain() !== 'D1'; + } ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + $provider->providerRevokeAccessForUser( 'foo' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'AuthPlugin failed to reset password for Foo in the following domains: D1', + $ex->getMessage() + ); + } + } + + public function testProviderAllowsPropertyChange() { + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'allowPropChange' ) + ->will( $this->returnCallback( function ( $prop ) { + return $prop === 'allow'; + } ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertTrue( $provider->providerAllowsPropertyChange( 'allow' ) ); + $this->assertFalse( $provider->providerAllowsPropertyChange( 'deny' ) ); + } + + /** + * @dataProvider provideProviderAllowsAuthenticationDataChange + * @param string $type + * @param bool|null $allow + * @param StatusValue $expect + */ + public function testProviderAllowsAuthenticationDataChange( $type, $allow, $expect ) { + $domains = $type instanceof PasswordDomainAuthenticationRequest ? [ 'foo', 'bar' ] : []; + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( $domains ); + $plugin->expects( $allow === null ? $this->never() : $this->once() ) + ->method( 'allowPasswordChange' )->will( $this->returnValue( $allow ) ); + $plugin->expects( $this->any() )->method( 'validDomain' ) + ->willReturnCallback( function ( $d ) use ( $domains ) { + return in_array( $d, $domains, true ); + } ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + if ( is_object( $type ) ) { + $req = $type; + } else { + $req = $this->createMock( $type ); + } + $req->action = AuthManager::ACTION_CHANGE; + $req->username = 'UTSysop'; + $req->password = 'Pa$$w0Rd!!!'; + $req->retype = 'Pa$$w0Rd!!!'; + $this->assertEquals( $expect, $provider->providerAllowsAuthenticationDataChange( $req ) ); + } + + public static function provideProviderAllowsAuthenticationDataChange() { + $domains = [ 'foo', 'bar' ]; + $reqNoDomain = new PasswordDomainAuthenticationRequest( $domains ); + $reqValidDomain = new PasswordDomainAuthenticationRequest( $domains ); + $reqValidDomain->domain = 'foo'; + $reqInvalidDomain = new PasswordDomainAuthenticationRequest( $domains ); + $reqInvalidDomain->domain = 'invalid'; + + return [ + [ AuthenticationRequest::class, null, \StatusValue::newGood( 'ignored' ) ], + [ new PasswordAuthenticationRequest, true, \StatusValue::newGood() ], + [ + new PasswordAuthenticationRequest, + false, + \StatusValue::newFatal( 'authmanager-authplugin-setpass-denied' ) + ], + [ $reqNoDomain, true, \StatusValue::newGood( 'ignored' ) ], + [ $reqValidDomain, true, \StatusValue::newGood() ], + [ + $reqInvalidDomain, + true, + \StatusValue::newFatal( 'authmanager-authplugin-setpass-bad-domain' ) + ], + ]; + } + + public function testProviderChangeAuthenticationData() { + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->never() )->method( 'setPassword' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $provider->providerChangeAuthenticationData( + $this->createMock( AuthenticationRequest::class ) + ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CHANGE; + $req->username = 'foo'; + $req->password = 'bar'; + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $provider->providerChangeAuthenticationData( $req ); + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + $provider->providerChangeAuthenticationData( $req ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \ErrorPageError $e ) { + $this->assertSame( 'authmanager-authplugin-setpass-failed-title', $e->title ); + $this->assertSame( 'authmanager-authplugin-setpass-failed-message', $e->msg ); + } + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' ) + ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) ); + $plugin->expects( $this->any() )->method( 'validDomain' ) + ->will( $this->returnCallback( function ( $domain ) { + return in_array( $domain, [ 'Domain1', 'Domain2' ] ); + } ) ); + $plugin->expects( $this->once() )->method( 'setDomain' ) + ->with( $this->equalTo( 'Domain2' ) ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_CREATE, [] ); + $req->username = 'foo'; + $req->password = 'bar'; + $req->domain = 'Domain2'; + $provider->providerChangeAuthenticationData( $req ); + } + + /** + * @dataProvider provideAccountCreationType + * @param bool $can + * @param string $expect + */ + public function testAccountCreationType( $can, $expect ) { + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() ) + ->method( 'canCreateAccounts' )->will( $this->returnValue( $can ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertSame( $expect, $provider->accountCreationType() ); + } + + public static function provideAccountCreationType() { + return [ + [ true, PrimaryAuthenticationProvider::TYPE_CREATE ], + [ false, PrimaryAuthenticationProvider::TYPE_NONE ], + ]; + } + + public function testTestForAccountCreation() { + $user = \User::newFromName( 'foo' ); + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + } + + public function testAccountCreation() { + $user = \User::newFromName( 'foo' ); + $user->setEmail( 'email' ); + $user->setRealName( 'realname' ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CREATE; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->never() )->method( 'addUser' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + $provider->beginPrimaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception was not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage() + ); + } + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->never() )->method( 'addUser' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'addUser' ) + ->with( + $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), + $this->equalTo( 'bar' ), + $this->equalTo( 'email' ), + $this->equalTo( 'realname' ) + ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newPass(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'addUser' ) + ->with( + $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), + $this->equalTo( 'bar' ), + $this->equalTo( 'email' ), + $this->equalTo( 'realname' ) + ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $ret = $provider->beginPrimaryAccountCreation( $user, $user, $reqs ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-authplugin-create-fail', $ret->message->getKey() ); + + $plugin = $this->createMock( \AuthPlugin::class ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->any() )->method( 'domainList' ) + ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) ); + $plugin->expects( $this->any() )->method( 'validDomain' ) + ->will( $this->returnCallback( function ( $domain ) { + return in_array( $domain, [ 'Domain1', 'Domain2' ] ); + } ) ); + $plugin->expects( $this->once() )->method( 'setDomain' ) + ->with( $this->equalTo( 'Domain2' ) ); + $plugin->expects( $this->once() )->method( 'addUser' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_CREATE, [] ); + $req->username = 'foo'; + $req->password = 'bar'; + $req->domain = 'Domain2'; + $provider->beginPrimaryAccountCreation( $user, $user, [ $req ] ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTest.php new file mode 100644 index 00000000..1bc0f31f --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTest.php @@ -0,0 +1,517 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\AuthenticationRequest + */ +class AuthenticationRequestTest extends \MediaWikiTestCase { + public function testBasics() { + $mock = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $this->assertSame( get_class( $mock ), $mock->getUniqueId() ); + + $this->assertType( 'array', $mock->getMetadata() ); + + $ret = $mock->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( \Message::class, $ret['provider'] ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( \Message::class, $ret['account'] ); + } + + public function testLoadRequestsFromSubmission() { + $mb = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'loadFromSubmission' ] ); + + $data = [ 'foo', 'bar' ]; + + $req1 = $mb->getMockForAbstractClass(); + $req1->expects( $this->once() )->method( 'loadFromSubmission' ) + ->with( $this->identicalTo( $data ) ) + ->will( $this->returnValue( false ) ); + + $req2 = $mb->getMockForAbstractClass(); + $req2->expects( $this->once() )->method( 'loadFromSubmission' ) + ->with( $this->identicalTo( $data ) ) + ->will( $this->returnValue( true ) ); + + $this->assertSame( + [ $req2 ], + AuthenticationRequest::loadRequestsFromSubmission( [ $req1, $req2 ], $data ) + ); + } + + public function testGetRequestByClass() { + $mb = $this->getMockBuilder( + AuthenticationRequest::class, 'AuthenticationRequestTest_AuthenticationRequest2' + ); + + $reqs = [ + $this->getMockForAbstractClass( + AuthenticationRequest::class, [], 'AuthenticationRequestTest_AuthenticationRequest1' + ), + $mb->getMockForAbstractClass(), + $mb->getMockForAbstractClass(), + $this->getMockForAbstractClass( + PasswordAuthenticationRequest::class, [], + 'AuthenticationRequestTest_PasswordAuthenticationRequest' + ), + ]; + + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest0' + ) ); + $this->assertSame( $reqs[0], AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest1' + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest2' + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, PasswordAuthenticationRequest::class + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'ClassThatDoesNotExist' + ) ); + + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest0', true + ) ); + $this->assertSame( $reqs[0], AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest1', true + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest2', true + ) ); + $this->assertSame( $reqs[3], AuthenticationRequest::getRequestByClass( + $reqs, PasswordAuthenticationRequest::class, true + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'ClassThatDoesNotExist', true + ) ); + } + + public function testGetUsernameFromRequests() { + $mb = $this->getMockBuilder( AuthenticationRequest::class ); + + for ( $i = 0; $i < 3; $i++ ) { + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'username' => [ + 'type' => 'string', + ], + ] ) ); + $reqs[] = $req; + } + + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [] ) ); + $req->username = 'baz'; + $reqs[] = $req; + + $this->assertNull( AuthenticationRequest::getUsernameFromRequests( $reqs ) ); + + $reqs[1]->username = 'foo'; + $this->assertSame( 'foo', AuthenticationRequest::getUsernameFromRequests( $reqs ) ); + + $reqs[0]->username = 'foo'; + $reqs[2]->username = 'foo'; + $this->assertSame( 'foo', AuthenticationRequest::getUsernameFromRequests( $reqs ) ); + + $reqs[1]->username = 'bar'; + try { + AuthenticationRequest::getUsernameFromRequests( $reqs ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Conflicting username fields: "bar" from ' . + get_class( $reqs[1] ) . '::$username vs. "foo" from ' . + get_class( $reqs[0] ) . '::$username', + $ex->getMessage() + ); + } + } + + public function testMergeFieldInfo() { + $msg = wfMessage( 'foo' ); + + $req1 = $this->createMock( AuthenticationRequest::class ); + $req1->required = AuthenticationRequest::REQUIRED; + $req1->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'string1' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + ], + 'string2' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + ], + 'optional' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + 'optional' => true, + ], + 'select' => [ + 'type' => 'select', + 'options' => [ 'foo' => $msg, 'baz' => $msg ], + 'label' => $msg, + 'help' => $msg, + ], + ] ) ); + + $req2 = $this->createMock( AuthenticationRequest::class ); + $req2->required = AuthenticationRequest::REQUIRED; + $req2->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'string1' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + 'sensitive' => true, + ], + 'string3' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + ], + 'select' => [ + 'type' => 'select', + 'options' => [ 'bar' => $msg, 'baz' => $msg ], + 'label' => $msg, + 'help' => $msg, + ], + ] ) ); + + $req3 = $this->createMock( AuthenticationRequest::class ); + $req3->required = AuthenticationRequest::REQUIRED; + $req3->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'string1' => [ + 'type' => 'checkbox', + 'label' => $msg, + 'help' => $msg, + ], + ] ) ); + + $req4 = $this->createMock( AuthenticationRequest::class ); + $req4->required = AuthenticationRequest::REQUIRED; + $req4->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [] ) ); + + // Basic combining + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1 ] ); + $expect = $req1->getFieldInfo(); + foreach ( $expect as $name => &$options ) { + $options['optional'] = !empty( $options['optional'] ); + $options['sensitive'] = !empty( $options['sensitive'] ); + } + unset( $options ); + $this->assertEquals( $expect, $fields ); + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req4 ] ); + $this->assertEquals( $expect, $fields ); + + try { + AuthenticationRequest::mergeFieldInfo( [ $req1, $req3 ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Field type conflict for "string1", "string" vs "checkbox"', + $ex->getMessage() + ); + } + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] ); + $expect += $req2->getFieldInfo(); + $expect['string1']['sensitive'] = true; + $expect['string2']['optional'] = false; + $expect['string3']['optional'] = false; + $expect['string3']['sensitive'] = false; + $expect['select']['options']['bar'] = $msg; + $this->assertEquals( $expect, $fields ); + + // Combining with something not required + + $req1->required = AuthenticationRequest::PRIMARY_REQUIRED; + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] ); + $expect += $req2->getFieldInfo(); + $expect['string1']['optional'] = false; + $expect['string1']['sensitive'] = true; + $expect['string3']['optional'] = false; + $expect['select']['optional'] = false; + $expect['select']['options']['bar'] = $msg; + $this->assertEquals( $expect, $fields ); + + $req2->required = AuthenticationRequest::PRIMARY_REQUIRED; + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] ); + $expect = $req1->getFieldInfo() + $req2->getFieldInfo(); + foreach ( $expect as $name => &$options ) { + $options['sensitive'] = !empty( $options['sensitive'] ); + } + $expect['string1']['optional'] = false; + $expect['string1']['sensitive'] = true; + $expect['string2']['optional'] = true; + $expect['string3']['optional'] = true; + $expect['select']['optional'] = false; + $expect['select']['options']['bar'] = $msg; + $this->assertEquals( $expect, $fields ); + } + + /** + * @dataProvider provideLoadFromSubmission + * @param array $fieldInfo + * @param array $data + * @param array|bool $expectState + */ + public function testLoadFromSubmission( $fieldInfo, $data, $expectState ) { + $mock = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $mock->expects( $this->any() )->method( 'getFieldInfo' ) + ->will( $this->returnValue( $fieldInfo ) ); + + $ret = $mock->loadFromSubmission( $data ); + if ( is_array( $expectState ) ) { + $this->assertTrue( $ret ); + $expect = call_user_func( [ get_class( $mock ), '__set_state' ], $expectState ); + $this->assertEquals( $expect, $mock ); + } else { + $this->assertFalse( $ret ); + } + } + + public static function provideLoadFromSubmission() { + return [ + 'No fields' => [ + [], + $data = [ 'foo' => 'bar' ], + false + ], + + 'Simple field' => [ + [ + 'field' => [ + 'type' => 'string', + ], + ], + $data = [ 'field' => 'string!' ], + $data + ], + 'Simple field, not supplied' => [ + [ + 'field' => [ + 'type' => 'string', + ], + ], + [], + false + ], + 'Simple field, empty' => [ + [ + 'field' => [ + 'type' => 'string', + ], + ], + [ 'field' => '' ], + false + ], + 'Simple field, optional, not supplied' => [ + [ + 'field' => [ + 'type' => 'string', + 'optional' => true, + ], + ], + [], + false + ], + 'Simple field, optional, empty' => [ + [ + 'field' => [ + 'type' => 'string', + 'optional' => true, + ], + ], + $data = [ 'field' => '' ], + $data + ], + + 'Checkbox, checked' => [ + [ + 'check' => [ + 'type' => 'checkbox', + ], + ], + [ 'check' => '' ], + [ 'check' => true ] + ], + 'Checkbox, unchecked' => [ + [ + 'check' => [ + 'type' => 'checkbox', + ], + ], + [], + false + ], + 'Checkbox, optional, unchecked' => [ + [ + 'check' => [ + 'type' => 'checkbox', + 'optional' => true, + ], + ], + [], + [ 'check' => false ] + ], + + 'Button, used' => [ + [ + 'push' => [ + 'type' => 'button', + ], + ], + [ 'push' => '' ], + [ 'push' => true ] + ], + 'Button, unused' => [ + [ + 'push' => [ + 'type' => 'button', + ], + ], + [], + false + ], + 'Button, optional, unused' => [ + [ + 'push' => [ + 'type' => 'button', + 'optional' => true, + ], + ], + [], + [ 'push' => false ] + ], + 'Button, image-style' => [ + [ + 'push' => [ + 'type' => 'button', + ], + ], + [ 'push_x' => 0, 'push_y' => 0 ], + [ 'push' => true ] + ], + + 'Select' => [ + [ + 'choose' => [ + 'type' => 'select', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => 'foo' ], + $data + ], + 'Select, invalid choice' => [ + [ + 'choose' => [ + 'type' => 'select', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => 'baz' ], + false + ], + 'Multiselect (2)' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => [ 'foo', 'bar' ] ], + $data + ], + 'Multiselect (1)' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => [ 'bar' ] ], + $data + ], + 'Multiselect, string for some reason' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + [ 'choose' => 'foo' ], + [ 'choose' => [ 'foo' ] ] + ], + 'Multiselect, invalid choice' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + [ 'choose' => [ 'foo', 'baz' ] ], + false + ], + 'Multiselect, empty' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + [ 'choose' => [] ], + false + ], + 'Multiselect, optional, nothing submitted' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + 'optional' => true, + ], + ], + [], + [ 'choose' => [] ] + ], + ]; + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php b/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php new file mode 100644 index 00000000..f483b9b6 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php @@ -0,0 +1,94 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + */ +abstract class AuthenticationRequestTestCase extends \MediaWikiTestCase { + abstract protected function getInstance( array $args = [] ); + + /** + * @dataProvider provideGetFieldInfo + */ + public function testGetFieldInfo( array $args ) { + $info = $this->getInstance( $args )->getFieldInfo(); + $this->assertType( 'array', $info ); + + foreach ( $info as $field => $data ) { + $this->assertType( 'array', $data, "Field $field" ); + $this->assertArrayHasKey( 'type', $data, "Field $field" ); + $this->assertArrayHasKey( 'label', $data, "Field $field" ); + $this->assertInstanceOf( \Message::class, $data['label'], "Field $field, label" ); + + if ( $data['type'] !== 'null' ) { + $this->assertArrayHasKey( 'help', $data, "Field $field" ); + $this->assertInstanceOf( \Message::class, $data['help'], "Field $field, help" ); + } + + if ( isset( $data['optional'] ) ) { + $this->assertType( 'bool', $data['optional'], "Field $field, optional" ); + } + if ( isset( $data['image'] ) ) { + $this->assertType( 'string', $data['image'], "Field $field, image" ); + } + if ( isset( $data['sensitive'] ) ) { + $this->assertType( 'bool', $data['sensitive'], "Field $field, sensitive" ); + } + if ( $data['type'] === 'password' ) { + $this->assertTrue( !empty( $data['sensitive'] ), + "Field $field, password field must be sensitive" ); + } + + switch ( $data['type'] ) { + case 'string': + case 'password': + case 'hidden': + break; + case 'select': + case 'multiselect': + $this->assertArrayHasKey( 'options', $data, "Field $field" ); + $this->assertType( 'array', $data['options'], "Field $field, options" ); + foreach ( $data['options'] as $val => $msg ) { + $this->assertInstanceOf( \Message::class, $msg, "Field $field, option $val" ); + } + break; + case 'checkbox': + break; + case 'button': + break; + case 'null': + break; + default: + $this->fail( "Field $field, unknown type " . $data['type'] ); + break; + } + } + } + + public static function provideGetFieldInfo() { + return [ + [ [] ] + ]; + } + + /** + * @dataProvider provideLoadFromSubmission + * @param array $args + * @param array $data + * @param array|bool $expectState + */ + public function testLoadFromSubmission( array $args, array $data, $expectState ) { + $instance = $this->getInstance( $args ); + $ret = $instance->loadFromSubmission( $data ); + if ( is_array( $expectState ) ) { + $this->assertTrue( $ret ); + $expect = call_user_func( [ get_class( $instance ), '__set_state' ], $expectState ); + $this->assertEquals( $expect, $instance ); + } else { + $this->assertFalse( $ret ); + } + } + + abstract public function provideLoadFromSubmission(); +} diff --git a/www/wiki/tests/phpunit/includes/auth/AuthenticationResponseTest.php b/www/wiki/tests/phpunit/includes/auth/AuthenticationResponseTest.php new file mode 100644 index 00000000..194b49e0 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/AuthenticationResponseTest.php @@ -0,0 +1,112 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\AuthenticationResponse + */ +class AuthenticationResponseTest extends \MediaWikiTestCase { + /** + * @dataProvider provideConstructors + * @param string $constructor + * @param array $args + * @param array|Exception $expect + */ + public function testConstructors( $constructor, $args, $expect ) { + if ( is_array( $expect ) ) { + $res = new AuthenticationResponse(); + $res->messageType = 'warning'; + foreach ( $expect as $field => $value ) { + $res->$field = $value; + } + $ret = call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); + $this->assertEquals( $res, $ret ); + } else { + try { + call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \Exception $ex ) { + $this->assertEquals( $expect, $ex ); + } + } + } + + public function provideConstructors() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $msg = new \Message( 'mainpage' ); + + return [ + [ 'newPass', [], [ + 'status' => AuthenticationResponse::PASS, + ] ], + [ 'newPass', [ 'name' ], [ + 'status' => AuthenticationResponse::PASS, + 'username' => 'name', + ] ], + [ 'newPass', [ 'name', null ], [ + 'status' => AuthenticationResponse::PASS, + 'username' => 'name', + ] ], + + [ 'newFail', [ $msg ], [ + 'status' => AuthenticationResponse::FAIL, + 'message' => $msg, + 'messageType' => 'error', + ] ], + + [ 'newRestart', [ $msg ], [ + 'status' => AuthenticationResponse::RESTART, + 'message' => $msg, + ] ], + + [ 'newAbstain', [], [ + 'status' => AuthenticationResponse::ABSTAIN, + ] ], + + [ 'newUI', [ [ $req ], $msg ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'warning', + ] ], + + [ 'newUI', [ [ $req ], $msg, 'warning' ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'warning', + ] ], + + [ 'newUI', [ [ $req ], $msg, 'error' ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'error', + ] ], + [ 'newUI', [ [], $msg ], + new \InvalidArgumentException( '$reqs may not be empty' ) + ], + + [ 'newRedirect', [ [ $req ], 'http://example.org/redir' ], [ + 'status' => AuthenticationResponse::REDIRECT, + 'neededRequests' => [ $req ], + 'redirectTarget' => 'http://example.org/redir', + ] ], + [ + 'newRedirect', + [ [ $req ], 'http://example.org/redir', [ 'foo' => 'bar' ] ], + [ + 'status' => AuthenticationResponse::REDIRECT, + 'neededRequests' => [ $req ], + 'redirectTarget' => 'http://example.org/redir', + 'redirectApiData' => [ 'foo' => 'bar' ], + ] + ], + [ 'newRedirect', [ [], 'http://example.org/redir' ], + new \InvalidArgumentException( '$reqs may not be empty' ) + ], + ]; + } + +} diff --git a/www/wiki/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php new file mode 100644 index 00000000..3bc077cb --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php @@ -0,0 +1,64 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\ButtonAuthenticationRequest + */ +class ButtonAuthenticationRequestTest extends AuthenticationRequestTestCase { + + protected function getInstance( array $args = [] ) { + $data = array_intersect_key( $args, [ 'name' => 1, 'label' => 1, 'help' => 1 ] ); + return ButtonAuthenticationRequest::__set_state( $data ); + } + + public static function provideGetFieldInfo() { + return [ + [ [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ] ] + ]; + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ], + [], + false + ], + 'Button present' => [ + [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ], + [ 'foo' => 'Foobar' ], + [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz', 'foo' => true ] + ], + ]; + } + + public function testGetUniqueId() { + $req = new ButtonAuthenticationRequest( 'foo', wfMessage( 'bar' ), wfMessage( 'baz' ) ); + $this->assertSame( + 'MediaWiki\\Auth\\ButtonAuthenticationRequest:foo', $req->getUniqueId() + ); + } + + public function testGetRequestByName() { + $reqs = []; + $reqs['testOne'] = new ButtonAuthenticationRequest( + 'foo', wfMessage( 'msg' ), wfMessage( 'help' ) + ); + $reqs[] = new ButtonAuthenticationRequest( 'bar', wfMessage( 'msg1' ), wfMessage( 'help1' ) ); + $reqs[] = new ButtonAuthenticationRequest( 'bar', wfMessage( 'msg2' ), wfMessage( 'help2' ) ); + $reqs['testSub'] = $this->getMockBuilder( ButtonAuthenticationRequest::class ) + ->setConstructorArgs( [ 'subclass', wfMessage( 'msg3' ), wfMessage( 'help3' ) ] ) + ->getMock(); + + $this->assertNull( ButtonAuthenticationRequest::getRequestByName( $reqs, 'missing' ) ); + $this->assertSame( + $reqs['testOne'], ButtonAuthenticationRequest::getRequestByName( $reqs, 'foo' ) + ); + $this->assertNull( ButtonAuthenticationRequest::getRequestByName( $reqs, 'bar' ) ); + $this->assertSame( + $reqs['testSub'], ButtonAuthenticationRequest::getRequestByName( $reqs, 'subclass' ) + ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php new file mode 100644 index 00000000..e8b61c59 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php @@ -0,0 +1,191 @@ +<?php + +namespace MediaWiki\Auth; + +use Wikimedia\TestingAccessWrapper; + +/** + * @group AuthManager + * @group Database + * @covers MediaWiki\Auth\CheckBlocksSecondaryAuthenticationProvider + */ +class CheckBlocksSecondaryAuthenticationProviderTest extends \MediaWikiTestCase { + public function testConstructor() { + $provider = new CheckBlocksSecondaryAuthenticationProvider(); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + $config = new \HashConfig( [ + 'BlockDisablesLogin' => false + ] ); + $provider->setConfig( $config ); + $this->assertSame( false, $providerPriv->blockDisablesLogin ); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => true ] + ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + $config = new \HashConfig( [ + 'BlockDisablesLogin' => false + ] ); + $provider->setConfig( $config ); + $this->assertSame( true, $providerPriv->blockDisablesLogin ); + } + + public function testBasics() { + $provider = new CheckBlocksSecondaryAuthenticationProvider(); + $user = \User::newFromName( 'UTSysop' ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAccountCreation( $user, $user, [] ) + ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + */ + public function testGetAuthenticationRequests( $action, $response ) { + $provider = new CheckBlocksSecondaryAuthenticationProvider(); + + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + private function getBlockedUser() { + $user = \User::newFromName( 'UTBlockee' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + \TestUser::setPasswordForUser( $user, 'UTBlockeePassword' ); + $user->saveSettings(); + } + $oldBlock = \Block::newFromTarget( 'UTBlockee' ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + $blockOptions = [ + 'address' => 'UTBlockee', + 'user' => $user->getID(), + 'by' => $this->getTestSysop()->getUser()->getId(), + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + return $user; + } + + public function testBeginSecondaryAuthentication() { + $unblockedUser = \User::newFromName( 'UTSysop' ); + $blockedUser = $this->getBlockedUser(); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => false ] + ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAuthentication( $unblockedUser, [] ) + ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAuthentication( $blockedUser, [] ) + ); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => true ] + ); + $this->assertEquals( + AuthenticationResponse::newPass(), + $provider->beginSecondaryAuthentication( $unblockedUser, [] ) + ); + $ret = $provider->beginSecondaryAuthentication( $blockedUser, [] ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status ); + } + + public function testTestUserForCreation() { + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => false ] + ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig() ); + $provider->setManager( AuthManager::singleton() ); + + $unblockedUser = \User::newFromName( 'UTSysop' ); + $blockedUser = $this->getBlockedUser(); + + $user = \User::newFromName( 'RandomUser' ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $unblockedUser, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $unblockedUser, false ) + ); + + $status = $provider->testUserForCreation( $blockedUser, AuthManager::AUTOCREATE_SOURCE_SESSION ); + $this->assertInstanceOf( \StatusValue::class, $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) ); + + $status = $provider->testUserForCreation( $blockedUser, false ); + $this->assertInstanceOf( \StatusValue::class, $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) ); + } + + public function testRangeBlock() { + $blockOptions = [ + 'address' => '127.0.0.0/24', + 'reason' => __METHOD__, + 'by' => $this->getTestSysop()->getUser()->getId(), + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + $scopeVariable = new \Wikimedia\ScopedCallback( [ $block, 'delete' ] ); + + $user = \User::newFromName( 'UTNormalUser' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + \TestUser::setPasswordForUser( $user, 'UTNormalUserPassword' ); + $user->saveSettings(); + } + $this->setMwGlobals( [ 'wgUser' => $user ] ); + \RequestContext::getMain()->setUser( $user ); + $newuser = \User::newFromName( 'RandomUser' ); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => true ] + ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig() ); + $provider->setManager( AuthManager::singleton() ); + + $ret = $provider->beginSecondaryAuthentication( $user, [] ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status ); + + $status = $provider->testUserForCreation( $newuser, AuthManager::AUTOCREATE_SOURCE_SESSION ); + $this->assertInstanceOf( \StatusValue::class, $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) ); + + $status = $provider->testUserForCreation( $newuser, false ); + $this->assertInstanceOf( \StatusValue::class, $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php new file mode 100644 index 00000000..f208cc4b --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php @@ -0,0 +1,68 @@ +<?php + +namespace MediaWiki\Auth; + +use InvalidArgumentException; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\ConfirmLinkAuthenticationRequest + */ +class ConfirmLinkAuthenticationRequestTest extends AuthenticationRequestTestCase { + + protected function getInstance( array $args = [] ) { + return new ConfirmLinkAuthenticationRequest( $this->getLinkRequests() ); + } + + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage $linkRequests must not be empty + */ + public function testConstructorException() { + new ConfirmLinkAuthenticationRequest( [] ); + } + + /** + * Get requests for testing + * @return AuthenticationRequest[] + */ + private function getLinkRequests() { + $reqs = []; + + $mb = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'getUniqueId' ] ); + for ( $i = 1; $i <= 3; $i++ ) { + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "Request$i" ) ); + $reqs[$req->getUniqueId()] = $req; + } + + return $reqs; + } + + public function provideLoadFromSubmission() { + $reqs = $this->getLinkRequests(); + + return [ + 'Empty request' => [ + [], + [], + [ 'linkRequests' => $reqs ], + ], + 'Some confirmed' => [ + [], + [ 'confirmedLinkIDs' => [ 'Request1', 'Request3' ] ], + [ 'confirmedLinkIDs' => [ 'Request1', 'Request3' ], 'linkRequests' => $reqs ], + ], + ]; + } + + public function testGetUniqueId() { + $req = new ConfirmLinkAuthenticationRequest( $this->getLinkRequests() ); + $this->assertSame( + get_class( $req ) . ':Request1|Request2|Request3', + $req->getUniqueId() + ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php new file mode 100644 index 00000000..9222843c --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php @@ -0,0 +1,289 @@ +<?php + +namespace MediaWiki\Auth; + +use Wikimedia\TestingAccessWrapper; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\ConfirmLinkSecondaryAuthenticationProvider + */ +class ConfirmLinkSecondaryAuthenticationProviderTest extends \MediaWikiTestCase { + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + */ + public function testGetAuthenticationRequests( $action, $response ) { + $provider = new ConfirmLinkSecondaryAuthenticationProvider(); + + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + public function testBeginSecondaryAuthentication() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'AuthManager::authnState' ) ) + ->will( $this->returnValue( $obj ) ); + $mock->expects( $this->never() )->method( 'continueLinkAttempt' ); + + $this->assertSame( $obj, $mock->beginSecondaryAuthentication( $user, [] ) ); + } + + public function testContinueSecondaryAuthentication() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = [ new \stdClass ]; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->never() )->method( 'beginLinkAttempt' ); + $mock->expects( $this->once() )->method( 'continueLinkAttempt' ) + ->with( + $this->identicalTo( $user ), + $this->identicalTo( 'AuthManager::authnState' ), + $this->identicalTo( $reqs ) + ) + ->will( $this->returnValue( $obj ) ); + + $this->assertSame( $obj, $mock->continueSecondaryAuthentication( $user, $reqs ) ); + } + + public function testBeginSecondaryAccountCreation() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'AuthManager::accountCreationState' ) ) + ->will( $this->returnValue( $obj ) ); + $mock->expects( $this->never() )->method( 'continueLinkAttempt' ); + + $this->assertSame( $obj, $mock->beginSecondaryAccountCreation( $user, $user, [] ) ); + } + + public function testContinueSecondaryAccountCreation() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = [ new \stdClass ]; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->never() )->method( 'beginLinkAttempt' ); + $mock->expects( $this->once() )->method( 'continueLinkAttempt' ) + ->with( + $this->identicalTo( $user ), + $this->identicalTo( 'AuthManager::accountCreationState' ), + $this->identicalTo( $reqs ) + ) + ->will( $this->returnValue( $obj ) ); + + $this->assertSame( $obj, $mock->continueSecondaryAccountCreation( $user, $user, $reqs ) ); + } + + /** + * Get requests for testing + * @return AuthenticationRequest[] + */ + private function getLinkRequests() { + $reqs = []; + + $mb = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'getUniqueId' ] ); + for ( $i = 1; $i <= 3; $i++ ) { + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "Request$i" ) ); + $req->id = $i - 1; + $reqs[$req->getUniqueId()] = $req; + } + + return $reqs; + } + + public function testBeginLinkAttempt() { + $badReq = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'getUniqueId' ] ) + ->getMockForAbstractClass(); + $badReq->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "BadReq" ) ); + + $user = \User::newFromName( 'UTSysop' ); + $provider = TestingAccessWrapper::newFromObject( + new ConfirmLinkSecondaryAuthenticationProvider + ); + $request = new \FauxRequest(); + $manager = $this->getMockBuilder( AuthManager::class ) + ->setMethods( [ 'allowsAuthenticationDataChange' ] ) + ->setConstructorArgs( [ $request, \RequestContext::getMain()->getConfig() ] ) + ->getMock(); + $manager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) { + return $req->getUniqueId() !== 'BadReq' + ? \StatusValue::newGood() + : \StatusValue::newFatal( 'no' ); + } ) ); + $provider->setManager( $manager ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginLinkAttempt( $user, 'state' ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [], + ] ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginLinkAttempt( $user, 'state' ) + ); + + $reqs = $this->getLinkRequests(); + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs + [ 'BadReq' => $badReq ] + ] ); + $res = $provider->beginLinkAttempt( $user, 'state' ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertSame( 'authprovider-confirmlink-message', $res->message->getKey() ); + $this->assertCount( 1, $res->neededRequests ); + $req = $res->neededRequests[0]; + $this->assertInstanceOf( ConfirmLinkAuthenticationRequest::class, $req ); + $expectReqs = $this->getLinkRequests(); + foreach ( $expectReqs as $r ) { + $r->action = AuthManager::ACTION_CHANGE; + $r->username = $user->getName(); + } + $this->assertEquals( $expectReqs, TestingAccessWrapper::newFromObject( $req )->linkRequests ); + } + + public function testContinueLinkAttempt() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = $this->getLinkRequests(); + + $done = [ false, false, false ]; + + // First, test the pass-through for not containing the ConfirmLinkAuthenticationRequest + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'state' ) ) + ->will( $this->returnValue( $obj ) ); + $this->assertSame( + $obj, + TestingAccessWrapper::newFromObject( $mock )->continueLinkAttempt( $user, 'state', $reqs ) + ); + + // Now test the actual functioning + $provider = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ + 'beginLinkAttempt', 'providerAllowsAuthenticationDataChange', + 'providerChangeAuthenticationData' + ] ) + ->getMock(); + $provider->expects( $this->never() )->method( 'beginLinkAttempt' ); + $provider->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) use ( $reqs ) { + return $req->getUniqueId() === 'Request3' + ? \StatusValue::newFatal( 'foo' ) : \StatusValue::newGood(); + } ) ); + $provider->expects( $this->any() )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) use ( &$done ) { + $done[$req->id] = true; + } ) ); + $config = new \HashConfig( [ + 'AuthManagerConfig' => [ + 'preauth' => [], + 'primaryauth' => [], + 'secondaryauth' => [ + [ 'factory' => function () use ( $provider ) { + return $provider; + } ], + ], + ], + ] ); + $request = new \FauxRequest(); + $manager = new AuthManager( $request, $config ); + $provider->setManager( $manager ); + $provider = TestingAccessWrapper::newFromObject( $provider ); + + $req = new ConfirmLinkAuthenticationRequest( $reqs ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [], + ] ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs + ] ); + $this->assertEquals( + AuthenticationResponse::newPass(), + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + $this->assertSame( [ false, false, false ], $done ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [ $reqs['Request2'] ], + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request2' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ false, true, false ], $done ); + $done = [ false, false, false ]; + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs, + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request2' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ true, true, false ], $done ); + $done = [ false, false, false ]; + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs, + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request3' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::UI, $res->status ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( ButtonAuthenticationRequest::class, $res->neededRequests[0] ); + $this->assertSame( [ true, false, false ], $done ); + $done = [ false, false, false ]; + + $res = $provider->continueLinkAttempt( $user, 'state', [ $res->neededRequests[0] ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ false, false, false ], $done ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php new file mode 100644 index 00000000..d166caa6 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php @@ -0,0 +1,57 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\CreateFromLoginAuthenticationRequest + */ +class CreateFromLoginAuthenticationRequestTest extends AuthenticationRequestTestCase { + + protected function getInstance( array $args = [] ) { + return new CreateFromLoginAuthenticationRequest( + null, [] + ); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [], + [], + [], + ], + ]; + } + + /** + * @dataProvider provideState + */ + public function testState( + $createReq, $maybeLink, $username, $loginState, $createState, $createPrimaryState + ) { + $req = new CreateFromLoginAuthenticationRequest( $createReq, $maybeLink ); + $this->assertSame( $username, $req->username ); + $this->assertSame( $loginState, $req->hasStateForAction( AuthManager::ACTION_LOGIN ) ); + $this->assertSame( $createState, $req->hasStateForAction( AuthManager::ACTION_CREATE ) ); + $this->assertFalse( $req->hasStateForAction( AuthManager::ACTION_LINK ) ); + $this->assertFalse( $req->hasPrimaryStateForAction( AuthManager::ACTION_LOGIN ) ); + $this->assertSame( $createPrimaryState, + $req->hasPrimaryStateForAction( AuthManager::ACTION_CREATE ) ); + } + + public static function provideState() { + $req1 = new UsernameAuthenticationRequest; + $req2 = new UsernameAuthenticationRequest; + $req2->username = 'Bob'; + + return [ + 'Nothing' => [ null, [], null, false, false, false ], + 'Link, no create' => [ null, [ $req2 ], null, true, true, false ], + 'No link, create but no name' => [ $req1, [], null, false, true, true ], + 'Link and create but no name' => [ $req1, [ $req2 ], null, true, true, true ], + 'No link, create with name' => [ $req2, [], 'Bob', false, true, true ], + 'Link and create with name' => [ $req2, [ $req2 ], 'Bob', true, true, true ], + ]; + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php new file mode 100644 index 00000000..fc1e6f15 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php @@ -0,0 +1,30 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\CreatedAccountAuthenticationRequest + */ +class CreatedAccountAuthenticationRequestTest extends AuthenticationRequestTestCase { + + protected function getInstance( array $args = [] ) { + return new CreatedAccountAuthenticationRequest( 42, 'Test' ); + } + + public function testConstructor() { + $ret = new CreatedAccountAuthenticationRequest( 42, 'Test' ); + $this->assertSame( 42, $ret->id ); + $this->assertSame( 'Test', $ret->username ); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [], + [], + false + ], + ]; + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php new file mode 100644 index 00000000..cce1e8cd --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php @@ -0,0 +1,34 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\CreationReasonAuthenticationRequest + */ +class CreationReasonAuthenticationRequestTest extends AuthenticationRequestTestCase { + + protected function getInstance( array $args = [] ) { + return new CreationReasonAuthenticationRequest(); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [], + [], + false + ], + 'Reason given' => [ + [], + $data = [ 'reason' => 'Because' ], + $data, + ], + 'Reason empty' => [ + [], + [ 'reason' => '' ], + false + ], + ]; + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php new file mode 100644 index 00000000..1a7ed12d --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php @@ -0,0 +1,109 @@ +<?php + +namespace MediaWiki\Auth; + +use Psr\Log\LoggerInterface; +use Wikimedia\TestingAccessWrapper; + +class EmailNotificationSecondaryAuthenticationProviderTest extends \PHPUnit\Framework\TestCase { + public function testConstructor() { + $config = new \HashConfig( [ + 'EnableEmail' => true, + 'EmailAuthentication' => true, + ] ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider(); + $provider->setConfig( $config ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + $this->assertTrue( $providerPriv->sendConfirmationEmail ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => false, + ] ); + $provider->setConfig( $config ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + $this->assertFalse( $providerPriv->sendConfirmationEmail ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param AuthenticationRequest[] $expected + */ + public function testGetAuthenticationRequests( $action, $expected ) { + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => true, + ] ); + $this->assertSame( $expected, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + public function testBeginSecondaryAuthentication() { + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => true, + ] ); + $this->assertEquals( AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAuthentication( \User::newFromName( 'Foo' ), [] ) ); + } + + public function testBeginSecondaryAccountCreation() { + $authManager = new AuthManager( new \FauxRequest(), new \HashConfig() ); + + $creator = $this->getMockBuilder( \User::class )->getMock(); + $userWithoutEmail = $this->getMockBuilder( \User::class )->getMock(); + $userWithoutEmail->expects( $this->any() )->method( 'getEmail' )->willReturn( '' ); + $userWithoutEmail->expects( $this->any() )->method( 'getInstanceForUpdate' )->willReturnSelf(); + $userWithoutEmail->expects( $this->never() )->method( 'sendConfirmationMail' ); + $userWithEmailError = $this->getMockBuilder( \User::class )->getMock(); + $userWithEmailError->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' ); + $userWithEmailError->expects( $this->any() )->method( 'getInstanceForUpdate' )->willReturnSelf(); + $userWithEmailError->expects( $this->any() )->method( 'sendConfirmationMail' ) + ->willReturn( \Status::newFatal( 'fail' ) ); + $userExpectsConfirmation = $this->getMockBuilder( \User::class )->getMock(); + $userExpectsConfirmation->expects( $this->any() )->method( 'getEmail' ) + ->willReturn( 'foo@bar.baz' ); + $userExpectsConfirmation->expects( $this->any() )->method( 'getInstanceForUpdate' ) + ->willReturnSelf(); + $userExpectsConfirmation->expects( $this->once() )->method( 'sendConfirmationMail' ) + ->willReturn( \Status::newGood() ); + $userNotExpectsConfirmation = $this->getMockBuilder( \User::class )->getMock(); + $userNotExpectsConfirmation->expects( $this->any() )->method( 'getEmail' ) + ->willReturn( 'foo@bar.baz' ); + $userNotExpectsConfirmation->expects( $this->any() )->method( 'getInstanceForUpdate' ) + ->willReturnSelf(); + $userNotExpectsConfirmation->expects( $this->never() )->method( 'sendConfirmationMail' ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => false, + ] ); + $provider->setManager( $authManager ); + $provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => true, + ] ); + $provider->setManager( $authManager ); + $provider->beginSecondaryAccountCreation( $userWithoutEmail, $creator, [] ); + $provider->beginSecondaryAccountCreation( $userExpectsConfirmation, $creator, [] ); + + // test logging of email errors + $logger = $this->getMockForAbstractClass( LoggerInterface::class ); + $logger->expects( $this->once() )->method( 'warning' ); + $provider->setLogger( $logger ); + $provider->beginSecondaryAccountCreation( $userWithEmailError, $creator, [] ); + + // test disable flag used by other providers + $authManager->setAuthenticationSessionData( 'no-email', true ); + $provider->setManager( $authManager ); + $provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php new file mode 100644 index 00000000..38ccb8a3 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php @@ -0,0 +1,373 @@ +<?php + +namespace MediaWiki\Auth; + +use MediaWiki\MediaWikiServices; + +/** + * @group AuthManager + * @group Database + * @covers MediaWiki\Auth\LegacyHookPreAuthenticationProvider + */ +class LegacyHookPreAuthenticationProviderTest extends \MediaWikiTestCase { + /** + * Get an instance of the provider + * @return LegacyHookPreAuthenticationProvider + */ + protected function getProvider() { + $request = $this->getMockBuilder( \FauxRequest::class ) + ->setMethods( [ 'getIP' ] )->getMock(); + $request->expects( $this->any() )->method( 'getIP' )->will( $this->returnValue( '127.0.0.42' ) ); + + $manager = new AuthManager( + $request, + MediaWikiServices::getInstance()->getMainConfig() + ); + + $provider = new LegacyHookPreAuthenticationProvider(); + $provider->setManager( $manager ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'PasswordAttemptThrottle' => [ 'count' => 23, 'seconds' => 42 ], + ] ) ); + return $provider; + } + + /** + * Sets a mock on a hook + * @param string $hook + * @param object $expect From $this->once(), $this->never(), etc. + * @return object $mock->expects( $expect )->method( ... ). + */ + protected function hook( $hook, $expect ) { + $mock = $this->getMockBuilder( __CLASS__ )->setMethods( [ "on$hook" ] )->getMock(); + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + $hook => [ $mock ], + ] ); + return $mock->expects( $expect )->method( "on$hook" ); + } + + /** + * Unsets a hook + * @param string $hook + */ + protected function unhook( $hook ) { + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + $hook => [], + ] ); + } + + // Stubs for hooks taking reference parameters + public function onLoginUserMigrated( $user, &$msg ) { + } + public function onAbortLogin( $user, $password, &$abort, &$msg ) { + } + public function onAbortNewAccount( $user, &$abortError, &$abortStatus ) { + } + public function onAbortAutoAccount( $user, &$abortError ) { + } + + /** + * @dataProvider provideTestForAuthentication + * @param string|null $username + * @param string|null $password + * @param string|null $msgForLoginUserMigrated + * @param int|null $abortForAbortLogin + * @param string|null $msgForAbortLogin + * @param string|null $failMsg + * @param array $failParams + */ + public function testTestForAuthentication( + $username, $password, + $msgForLoginUserMigrated, $abortForAbortLogin, $msgForAbortLogin, + $failMsg, $failParams = [] + ) { + $reqs = []; + if ( $username === null ) { + $this->hook( 'LoginUserMigrated', $this->never() ); + $this->hook( 'AbortLogin', $this->never() ); + } else { + if ( $password === null ) { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + } else { + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $req->password = $password; + } + $req->username = $username; + $reqs[get_class( $req )] = $req; + + $h = $this->hook( 'LoginUserMigrated', $this->once() ); + if ( $msgForLoginUserMigrated !== null ) { + $h->will( $this->returnCallback( + function ( $user, &$msg ) use ( $username, $msgForLoginUserMigrated ) { + $this->assertInstanceOf( \User::class, $user ); + $this->assertSame( $username, $user->getName() ); + $msg = $msgForLoginUserMigrated; + return false; + } + ) ); + $this->hook( 'AbortLogin', $this->never() ); + } else { + $h->will( $this->returnCallback( + function ( $user, &$msg ) use ( $username ) { + $this->assertInstanceOf( \User::class, $user ); + $this->assertSame( $username, $user->getName() ); + return true; + } + ) ); + $h2 = $this->hook( 'AbortLogin', $this->once() ); + if ( $abortForAbortLogin !== null ) { + $h2->will( $this->returnCallback( + function ( $user, $pass, &$abort, &$msg ) + use ( $username, $password, $abortForAbortLogin, $msgForAbortLogin ) + { + $this->assertInstanceOf( \User::class, $user ); + $this->assertSame( $username, $user->getName() ); + if ( $password !== null ) { + $this->assertSame( $password, $pass ); + } else { + $this->assertInternalType( 'string', $pass ); + } + $abort = $abortForAbortLogin; + $msg = $msgForAbortLogin; + return false; + } + ) ); + } else { + $h2->will( $this->returnCallback( + function ( $user, $pass, &$abort, &$msg ) use ( $username, $password ) { + $this->assertInstanceOf( \User::class, $user ); + $this->assertSame( $username, $user->getName() ); + if ( $password !== null ) { + $this->assertSame( $password, $pass ); + } else { + $this->assertInternalType( 'string', $pass ); + } + return true; + } + ) ); + } + } + } + unset( $h, $h2 ); + + $status = $this->getProvider()->testForAuthentication( $reqs ); + + $this->unhook( 'LoginUserMigrated' ); + $this->unhook( 'AbortLogin' ); + + if ( $failMsg === null ) { + $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' ); + } else { + $this->assertInstanceOf( \StatusValue::class, $status, 'should fail (type)' ); + $this->assertFalse( $status->isOk(), 'should fail (ok)' ); + $errors = $status->getErrors(); + $this->assertEquals( $failMsg, $errors[0]['message'], 'should fail (message)' ); + $this->assertEquals( $failParams, $errors[0]['params'], 'should fail (params)' ); + } + } + + public static function provideTestForAuthentication() { + return [ + 'No valid requests' => [ + null, null, null, null, null, null + ], + 'No hook errors' => [ + 'User', 'PaSsWoRd', null, null, null, null + ], + 'No hook errors, no password' => [ + 'User', null, null, null, null, null + ], + 'LoginUserMigrated no message' => [ + 'User', 'PaSsWoRd', false, null, null, 'login-migrated-generic' + ], + 'LoginUserMigrated with message' => [ + 'User', 'PaSsWoRd', 'LUM-abort', null, null, 'LUM-abort' + ], + 'LoginUserMigrated with message and params' => [ + 'User', 'PaSsWoRd', [ 'LUM-abort', 'foo' ], null, null, 'LUM-abort', [ 'foo' ] + ], + 'AbortLogin, SUCCESS' => [ + 'User', 'PaSsWoRd', null, \LoginForm::SUCCESS, null, null + ], + 'AbortLogin, NEED_TOKEN, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NEED_TOKEN, null, 'nocookiesforlogin' + ], + 'AbortLogin, NEED_TOKEN, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NEED_TOKEN, 'needtoken', 'needtoken' + ], + 'AbortLogin, WRONG_TOKEN, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_TOKEN, null, 'sessionfailure' + ], + 'AbortLogin, WRONG_TOKEN, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_TOKEN, 'wrongtoken', 'wrongtoken' + ], + 'AbortLogin, ILLEGAL, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::ILLEGAL, null, 'noname' + ], + 'AbortLogin, ILLEGAL, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::ILLEGAL, 'badname', 'badname' + ], + 'AbortLogin, NO_NAME, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NO_NAME, null, 'noname' + ], + 'AbortLogin, NO_NAME, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NO_NAME, 'badname', 'badname' + ], + 'AbortLogin, WRONG_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PASS, null, 'wrongpassword' + ], + 'AbortLogin, WRONG_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PASS, 'badpass', 'badpass' + ], + 'AbortLogin, WRONG_PLUGIN_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PLUGIN_PASS, null, 'wrongpassword' + ], + 'AbortLogin, WRONG_PLUGIN_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PLUGIN_PASS, 'badpass', 'badpass' + ], + 'AbortLogin, NOT_EXISTS, no message' => [ + "User'", 'A', null, \LoginForm::NOT_EXISTS, null, 'nosuchusershort', [ 'User'' ] + ], + 'AbortLogin, NOT_EXISTS, with message' => [ + "User'", 'A', null, \LoginForm::NOT_EXISTS, 'badname', 'badname', [ 'User'' ] + ], + 'AbortLogin, EMPTY_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::EMPTY_PASS, null, 'wrongpasswordempty' + ], + 'AbortLogin, EMPTY_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::EMPTY_PASS, 'badpass', 'badpass' + ], + 'AbortLogin, RESET_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::RESET_PASS, null, 'resetpass_announce' + ], + 'AbortLogin, RESET_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::RESET_PASS, 'resetpass', 'resetpass' + ], + 'AbortLogin, THROTTLED, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::THROTTLED, null, 'login-throttled', + [ \Message::durationParam( 42 ) ] + ], + 'AbortLogin, THROTTLED, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::THROTTLED, 't', 't', + [ \Message::durationParam( 42 ) ] + ], + 'AbortLogin, USER_BLOCKED, no message' => [ + "User'", 'P', null, \LoginForm::USER_BLOCKED, null, 'login-userblocked', [ 'User'' ] + ], + 'AbortLogin, USER_BLOCKED, with message' => [ + "User'", 'P', null, \LoginForm::USER_BLOCKED, 'blocked', 'blocked', [ 'User'' ] + ], + 'AbortLogin, ABORTED, no message' => [ + "User'", 'P', null, \LoginForm::ABORTED, null, 'login-abort-generic', [ 'User'' ] + ], + 'AbortLogin, ABORTED, with message' => [ + "User'", 'P', null, \LoginForm::ABORTED, 'aborted', 'aborted', [ 'User'' ] + ], + 'AbortLogin, USER_MIGRATED, no message' => [ + 'User', 'P', null, \LoginForm::USER_MIGRATED, null, 'login-migrated-generic' + ], + 'AbortLogin, USER_MIGRATED, with message' => [ + 'User', 'P', null, \LoginForm::USER_MIGRATED, 'migrated', 'migrated' + ], + 'AbortLogin, USER_MIGRATED, with message and params' => [ + 'User', 'P', null, \LoginForm::USER_MIGRATED, [ 'migrated', 'foo' ], + 'migrated', [ 'foo' ] + ], + ]; + } + + /** + * @dataProvider provideTestForAccountCreation + * @param string $msg + * @param Status|null $status + * @param StatusValue $result + */ + public function testTestForAccountCreation( $msg, $status, $result ) { + $this->hook( 'AbortNewAccount', $this->once() ) + ->will( $this->returnCallback( function ( $user, &$error, &$abortStatus ) + use ( $msg, $status ) + { + $this->assertInstanceOf( \User::class, $user ); + $this->assertSame( 'User', $user->getName() ); + $error = $msg; + $abortStatus = $status; + return $error === null && $status === null; + } ) ); + + $user = \User::newFromName( 'User' ); + $creator = \User::newFromName( 'UTSysop' ); + $ret = $this->getProvider()->testForAccountCreation( $user, $creator, [] ); + + $this->unhook( 'AbortNewAccount' ); + + $this->assertEquals( $result, $ret ); + } + + public static function provideTestForAccountCreation() { + return [ + 'No hook errors' => [ + null, null, \StatusValue::newGood() + ], + 'AbortNewAccount, old style' => [ + 'foobar', null, \StatusValue::newFatal( + \Message::newFromKey( 'createaccount-hook-aborted' )->rawParams( 'foobar' ) + ) + ], + 'AbortNewAccount, new style' => [ + 'foobar', + \Status::newFatal( 'aborted!', 'param' ), + \StatusValue::newFatal( 'aborted!', 'param' ) + ], + ]; + } + + /** + * @dataProvider provideTestUserForCreation + * @param string|null $error + * @param string|null $failMsg + */ + public function testTestUserForCreation( $error, $failMsg ) { + $testUser = self::getTestUser()->getUser(); + $provider = $this->getProvider(); + $options = [ 'flags' => \User::READ_LOCKING, 'creating' => true ]; + + $this->hook( 'AbortNewAccount', $this->never() ); + $this->hook( 'AbortAutoAccount', $this->once() ) + ->will( $this->returnCallback( function ( $user, &$abortError ) use ( $testUser, $error ) { + $this->assertInstanceOf( \User::class, $user ); + $this->assertSame( $testUser->getName(), $user->getName() ); + $abortError = $error; + return $error === null; + } ) ); + $status = $provider->testUserForCreation( + $testUser, AuthManager::AUTOCREATE_SOURCE_SESSION, $options + ); + $this->unhook( 'AbortNewAccount' ); + $this->unhook( 'AbortAutoAccount' ); + if ( $failMsg === null ) { + $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' ); + } else { + $this->assertInstanceOf( \StatusValue::class, $status, 'should fail (type)' ); + $this->assertFalse( $status->isOk(), 'should fail (ok)' ); + $errors = $status->getErrors(); + $this->assertEquals( $failMsg, $errors[0]['message'], 'should fail (message)' ); + } + + $this->hook( 'AbortAutoAccount', $this->never() ); + $this->hook( 'AbortNewAccount', $this->never() ); + $status = $provider->testUserForCreation( $testUser, false, $options ); + $this->unhook( 'AbortNewAccount' ); + $this->unhook( 'AbortAutoAccount' ); + $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' ); + } + + public static function provideTestUserForCreation() { + return [ + 'Success' => [ null, null ], + 'Fail, no message' => [ false, 'login-abort-generic' ], + 'Fail, with message' => [ 'fail', 'fail' ], + ]; + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php new file mode 100644 index 00000000..5f370785 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php @@ -0,0 +1,658 @@ +<?php + +namespace MediaWiki\Auth; + +use MediaWiki\MediaWikiServices; +use Wikimedia\TestingAccessWrapper; + +/** + * @group AuthManager + * @group Database + * @covers MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider + */ +class LocalPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestCase { + + private $manager = null; + private $config = null; + private $validity = null; + + /** + * Get an instance of the provider + * + * $provider->checkPasswordValidity is mocked to return $this->validity, + * because we don't need to test that here. + * + * @param bool $loginOnly + * @return LocalPasswordPrimaryAuthenticationProvider + */ + protected function getProvider( $loginOnly = false ) { + if ( !$this->config ) { + $this->config = new \HashConfig(); + } + $config = new \MultiConfig( [ + $this->config, + MediaWikiServices::getInstance()->getMainConfig() + ] ); + + if ( !$this->manager ) { + $this->manager = new AuthManager( new \FauxRequest(), $config ); + } + $this->validity = \Status::newGood(); + + $provider = $this->getMockBuilder( LocalPasswordPrimaryAuthenticationProvider::class ) + ->setMethods( [ 'checkPasswordValidity' ] ) + ->setConstructorArgs( [ [ 'loginOnly' => $loginOnly ] ] ) + ->getMock(); + $provider->expects( $this->any() )->method( 'checkPasswordValidity' ) + ->will( $this->returnCallback( function () { + return $this->validity; + } ) ); + $provider->setConfig( $config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $this->manager ); + + return $provider; + } + + public function testBasics() { + $user = $this->getMutableTestUser()->getUser(); + $userName = $user->getName(); + $lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 ); + + $provider = new LocalPasswordPrimaryAuthenticationProvider(); + + $this->assertSame( + PrimaryAuthenticationProvider::TYPE_CREATE, + $provider->accountCreationType() + ); + + $this->assertTrue( $provider->testUserExists( $userName ) ); + $this->assertTrue( $provider->testUserExists( $lowerInitialUserName ) ); + $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) ); + $this->assertFalse( $provider->testUserExists( '<invalid>' ) ); + + $provider = new LocalPasswordPrimaryAuthenticationProvider( [ 'loginOnly' => true ] ); + + $this->assertSame( + PrimaryAuthenticationProvider::TYPE_NONE, + $provider->accountCreationType() + ); + + $this->assertTrue( $provider->testUserExists( $userName ) ); + $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) ); + + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_CHANGE; + $req->username = '<invalid>'; + $provider->providerChangeAuthenticationData( $req ); + } + + public function testTestUserCanAuthenticate() { + $user = $this->getMutableTestUser()->getUser(); + $userName = $user->getName(); + $dbw = wfGetDB( DB_MASTER ); + + $provider = $this->getProvider(); + + $this->assertFalse( $provider->testUserCanAuthenticate( '<invalid>' ) ); + + $this->assertFalse( $provider->testUserCanAuthenticate( 'DoesNotExist' ) ); + + $this->assertTrue( $provider->testUserCanAuthenticate( $userName ) ); + $lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 ); + $this->assertTrue( $provider->testUserCanAuthenticate( $lowerInitialUserName ) ); + + $dbw->update( + 'user', + [ 'user_password' => \PasswordFactory::newInvalidPassword()->toString() ], + [ 'user_name' => $userName ] + ); + $this->assertFalse( $provider->testUserCanAuthenticate( $userName ) ); + + // Really old format + $dbw->update( + 'user', + [ 'user_password' => '0123456789abcdef0123456789abcdef' ], + [ 'user_name' => $userName ] + ); + $this->assertTrue( $provider->testUserCanAuthenticate( $userName ) ); + } + + public function testSetPasswordResetFlag() { + // Set instance vars + $this->getProvider(); + + /// @todo: Because we're currently using User, which uses the global config... + $this->setMwGlobals( [ 'wgPasswordExpireGrace' => 100 ] ); + + $this->config->set( 'PasswordExpireGrace', 100 ); + $this->config->set( 'InvalidPasswordReset', true ); + + $provider = new LocalPasswordPrimaryAuthenticationProvider(); + $provider->setConfig( $this->config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $this->manager ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + + $user = $this->getMutableTestUser()->getUser(); + $userName = $user->getName(); + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->selectRow( + 'user', + '*', + [ 'user_name' => $userName ], + __METHOD__ + ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = wfTimestamp( TS_MW, time() + 200 ); + $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = wfTimestamp( TS_MW, time() - 200 ); + $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row ); + $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-expired', $ret->msg->getKey() ); + $this->assertTrue( $ret->hard ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = wfTimestamp( TS_MW, time() - 1 ); + $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row ); + $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-expired-soft', $ret->msg->getKey() ); + $this->assertFalse( $ret->hard ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = null; + $status = \Status::newGood(); + $status->error( 'testing' ); + $providerPriv->setPasswordResetFlag( $userName, $status, $row ); + $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() ); + $this->assertFalse( $ret->hard ); + } + + public function testAuthentication() { + $testUser = $this->getMutableTestUser(); + $userName = $testUser->getUser()->getName(); + + $dbw = wfGetDB( DB_MASTER ); + $id = \User::idFromName( $userName ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + + // General failures + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = '<invalid>'; + $req->password = 'WhoCares'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = 'DoesNotExist'; + $req->password = 'DoesNotExist'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + // Validation failure + $req->username = $userName; + $req->password = $testUser->getPassword(); + $this->validity = \Status::newFatal( 'arbitrary-failure' ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'arbitrary-failure', + $ret->message->getKey() + ); + + // Successful auth + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + // Successful auth after normalizing name + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $req->username = mb_strtolower( $userName[0] ) . substr( $userName, 1 ); + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + $req->username = $userName; + + // Successful auth with reset + $this->manager->removeAuthenticationSessionData( null ); + $this->validity->error( 'arbitrary-warning' ); + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + // Wrong password + $this->validity = \Status::newGood(); + $req->password = 'Wrong'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + // Correct handling of legacy encodings + $password = ':B:salt:' . md5( 'salt-' . md5( "\xe1\xe9\xed\xf3\xfa" ) ); + $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] ); + $req->password = 'áéÃóú'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + $this->config->set( 'LegacyEncoding', true ); + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->password = 'áéÃóú Wrong'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + // Correct handling of really old password hashes + $this->config->set( 'PasswordSalt', false ); + $password = md5( 'FooBar' ); + $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] ); + $req->password = 'FooBar'; + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $this->config->set( 'PasswordSalt', true ); + $password = md5( "$id-" . md5( 'FooBar' ) ); + $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] ); + $req->password = 'FooBar'; + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + } + + /** + * @dataProvider provideProviderAllowsAuthenticationDataChange + * @param string $type + * @param string $user + * @param \Status $validity Result of the password validity check + * @param \StatusValue $expect1 Expected result with $checkData = false + * @param \StatusValue $expect2 Expected result with $checkData = true + */ + public function testProviderAllowsAuthenticationDataChange( $type, $user, \Status $validity, + \StatusValue $expect1, \StatusValue $expect2 + ) { + if ( $type === PasswordAuthenticationRequest::class ) { + $req = new $type(); + } elseif ( $type === PasswordDomainAuthenticationRequest::class ) { + $req = new $type( [] ); + } else { + $req = $this->createMock( $type ); + } + $req->action = AuthManager::ACTION_CHANGE; + $req->username = $user; + $req->password = 'NewPassword'; + $req->retype = 'NewPassword'; + + $provider = $this->getProvider(); + $this->validity = $validity; + $this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) ); + $this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) ); + + $req->retype = 'BadRetype'; + $this->assertEquals( + $expect1, + $provider->providerAllowsAuthenticationDataChange( $req, false ) + ); + $this->assertEquals( + $expect2->getValue() === 'ignored' ? $expect2 : \StatusValue::newFatal( 'badretype' ), + $provider->providerAllowsAuthenticationDataChange( $req, true ) + ); + + $provider = $this->getProvider( true ); + $this->assertEquals( + \StatusValue::newGood( 'ignored' ), + $provider->providerAllowsAuthenticationDataChange( $req, true ), + 'loginOnly mode should claim to ignore all changes' + ); + } + + public static function provideProviderAllowsAuthenticationDataChange() { + $err = \StatusValue::newGood(); + $err->error( 'arbitrary-warning' ); + + return [ + [ AuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ PasswordAuthenticationRequest::class, 'uTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::wrap( $err ), + \StatusValue::newGood(), $err ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newFatal( 'arbitrary-error' ), + \StatusValue::newGood(), \StatusValue::newFatal( 'arbitrary-error' ) ], + [ PasswordAuthenticationRequest::class, 'DoesNotExist', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ], + [ PasswordDomainAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + ]; + } + + /** + * @dataProvider provideProviderChangeAuthenticationData + * @param callable|bool $usernameTransform + * @param string $type + * @param bool $loginOnly + * @param bool $changed + */ + public function testProviderChangeAuthenticationData( + $usernameTransform, $type, $loginOnly, $changed ) { + $testUser = $this->getMutableTestUser(); + $user = $testUser->getUser()->getName(); + if ( is_callable( $usernameTransform ) ) { + $user = call_user_func( $usernameTransform, $user ); + } + $cuser = ucfirst( $user ); + $oldpass = $testUser->getPassword(); + $newpass = 'NewPassword'; + + $dbw = wfGetDB( DB_MASTER ); + $oldExpiry = $dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] ); + + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'ResetPasswordExpiration' => [ function ( $user, &$expires ) { + $expires = '30001231235959'; + } ] + ] ); + + $provider = $this->getProvider( $loginOnly ); + + // Sanity check + $loginReq = new PasswordAuthenticationRequest(); + $loginReq->action = AuthManager::ACTION_LOGIN; + $loginReq->username = $user; + $loginReq->password = $oldpass; + $loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ]; + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $provider->beginPrimaryAuthentication( $loginReqs ), + 'Sanity check' + ); + + if ( $type === PasswordAuthenticationRequest::class ) { + $changeReq = new $type(); + } else { + $changeReq = $this->createMock( $type ); + } + $changeReq->action = AuthManager::ACTION_CHANGE; + $changeReq->username = $user; + $changeReq->password = $newpass; + $provider->providerChangeAuthenticationData( $changeReq ); + + if ( $loginOnly && $changed ) { + $old = 'fail'; + $new = 'fail'; + $expectExpiry = null; + } elseif ( $changed ) { + $old = 'fail'; + $new = 'pass'; + $expectExpiry = '30001231235959'; + } else { + $old = 'pass'; + $new = 'fail'; + $expectExpiry = $oldExpiry; + } + + $loginReq->password = $oldpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + if ( $old === 'pass' ) { + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $ret, + 'old password should pass' + ); + } else { + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'old password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'old password should fail' + ); + } + + $loginReq->password = $newpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + if ( $new === 'pass' ) { + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $ret, + 'new password should pass' + ); + } else { + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'new password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'new password should fail' + ); + } + + $this->assertSame( + $expectExpiry, + wfTimestampOrNull( + TS_MW, + $dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] ) + ) + ); + } + + public static function provideProviderChangeAuthenticationData() { + return [ + [ false, AuthenticationRequest::class, false, false ], + [ false, PasswordAuthenticationRequest::class, false, true ], + [ false, AuthenticationRequest::class, true, false ], + [ false, PasswordAuthenticationRequest::class, true, true ], + [ 'ucfirst', PasswordAuthenticationRequest::class, false, true ], + [ 'ucfirst', PasswordAuthenticationRequest::class, true, true ], + ]; + } + + public function testTestForAccountCreation() { + $user = \User::newFromName( 'foo' ); + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CREATE; + $req->username = 'Foo'; + $req->password = 'Bar'; + $req->retype = 'Bar'; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ), + 'No password request' + ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, validated' + ); + + $req->retype = 'Baz'; + $this->assertEquals( + \StatusValue::newFatal( 'badretype' ), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, bad retype' + ); + $req->retype = 'Bar'; + + $this->validity->error( 'arbitrary warning' ); + $expect = \StatusValue::newGood(); + $expect->error( 'arbitrary warning' ); + $this->assertEquals( + $expect, + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, not validated' + ); + + $provider = $this->getProvider( true ); + $this->validity->error( 'arbitrary warning' ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, not validated, loginOnly' + ); + } + + public function testAccountCreation() { + $user = \User::newFromName( 'Foo' ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CREATE; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider( true ); + try { + $provider->beginPrimaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception was not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage() + ); + } + + try { + $provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() ); + $this->fail( 'Expected exception was not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage() + ); + } + + $provider = $this->getProvider( false ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $expect = AuthenticationResponse::newPass( 'Foo' ); + $expect->createRequest = clone $req; + $expect->createRequest->username = 'Foo'; + $this->assertEquals( $expect, $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) ); + + // We have to cheat a bit to avoid having to add a new user to + // the database to test the actual setting of the password works right + $dbw = wfGetDB( DB_MASTER ); + + $user = \User::newFromName( 'UTSysop' ); + $req->username = $user->getName(); + $req->password = 'NewPassword'; + $expect = AuthenticationResponse::newPass( 'UTSysop' ); + $expect->createRequest = $req; + + $res2 = $provider->beginPrimaryAccountCreation( $user, $user, $reqs ); + $this->assertEquals( $expect, $res2, 'Sanity check' ); + + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status, 'sanity check' ); + + $this->assertNull( $provider->finishAccountCreation( $user, $user, $res2 ) ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( AuthenticationResponse::PASS, $ret->status, 'new password is set' ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php new file mode 100644 index 00000000..1ef675b6 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php @@ -0,0 +1,138 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\PasswordAuthenticationRequest + */ +class PasswordAuthenticationRequestTest extends AuthenticationRequestTestCase { + + protected function getInstance( array $args = [] ) { + $ret = new PasswordAuthenticationRequest(); + $ret->action = $args[0]; + return $ret; + } + + public static function provideGetFieldInfo() { + return [ + [ [ AuthManager::ACTION_LOGIN ] ], + [ [ AuthManager::ACTION_CREATE ] ], + [ [ AuthManager::ACTION_CHANGE ] ], + [ [ AuthManager::ACTION_REMOVE ] ], + ]; + } + + public function testGetFieldInfo2() { + $info = []; + foreach ( [ + AuthManager::ACTION_LOGIN, + AuthManager::ACTION_CREATE, + AuthManager::ACTION_CHANGE, + AuthManager::ACTION_REMOVE, + ] as $action ) { + $req = new PasswordAuthenticationRequest(); + $req->action = $action; + $info[$action] = $req->getFieldInfo(); + } + + $this->assertSame( [], $info[AuthManager::ACTION_REMOVE], 'No data needed to remove' ); + + $this->assertArrayNotHasKey( 'retype', $info[AuthManager::ACTION_LOGIN], + 'No need to retype password on login' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CREATE], + 'Need to retype when creating new password' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CHANGE], + 'Need to retype when changing password' ); + + $this->assertNotEquals( + $info[AuthManager::ACTION_LOGIN]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from login' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from create' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['retype']['label'], + $info[AuthManager::ACTION_CHANGE]['retype']['label'], + 'Retype field for change is differentiated from create' + ); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request, login' => [ + [ AuthManager::ACTION_LOGIN ], + [], + false, + ], + 'Empty request, change' => [ + [ AuthManager::ACTION_CHANGE ], + [], + false, + ], + 'Empty request, remove' => [ + [ AuthManager::ACTION_REMOVE ], + [], + false, + ], + 'Username + password, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar' ], + $data + [ 'action' => AuthManager::ACTION_LOGIN ], + ], + 'Username + password, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar' ], + false, + ], + 'Username + password + retype' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => 'baz' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ], + ], + 'Username empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => '', 'password' => 'Bar' ], + false, + ], + 'Username empty, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => '', 'password' => 'Bar', 'retype' => 'baz' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ], + ], + 'Password empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '' ], + false, + ], + 'Password empty, login, with retype' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '', 'retype' => 'baz' ], + false, + ], + 'Retype empty' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => '' ], + false, + ], + ]; + } + + public function testDescribeCredentials() { + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_LOGIN; + $req->username = 'UTSysop'; + $ret = $req->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( \Message::class, $ret['provider'] ); + $this->assertSame( 'authmanager-provider-password', $ret['provider']->getKey() ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( \Message::class, $ret['account'] ); + $this->assertSame( [ 'UTSysop' ], $ret['account']->getParams() ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php new file mode 100644 index 00000000..36be4243 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php @@ -0,0 +1,159 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\PasswordDomainAuthenticationRequest + */ +class PasswordDomainAuthenticationRequestTest extends AuthenticationRequestTestCase { + + protected function getInstance( array $args = [] ) { + $ret = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] ); + $ret->action = $args[0]; + return $ret; + } + + public static function provideGetFieldInfo() { + return [ + [ [ AuthManager::ACTION_LOGIN ] ], + [ [ AuthManager::ACTION_CREATE ] ], + [ [ AuthManager::ACTION_CHANGE ] ], + [ [ AuthManager::ACTION_REMOVE ] ], + ]; + } + + public function testGetFieldInfo2() { + $info = []; + foreach ( [ + AuthManager::ACTION_LOGIN, + AuthManager::ACTION_CREATE, + AuthManager::ACTION_CHANGE, + AuthManager::ACTION_REMOVE, + ] as $action ) { + $req = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] ); + $req->action = $action; + $info[$action] = $req->getFieldInfo(); + } + + $this->assertSame( [], $info[AuthManager::ACTION_REMOVE], 'No data needed to remove' ); + + $this->assertArrayNotHasKey( 'retype', $info[AuthManager::ACTION_LOGIN], + 'No need to retype password on login' ); + $this->assertArrayHasKey( 'domain', $info[AuthManager::ACTION_LOGIN], + 'Domain needed on login' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CREATE], + 'Need to retype when creating new password' ); + $this->assertArrayHasKey( 'domain', $info[AuthManager::ACTION_CREATE], + 'Domain needed on account creation' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CHANGE], + 'Need to retype when changing password' ); + $this->assertArrayNotHasKey( 'domain', $info[AuthManager::ACTION_CHANGE], + 'Domain not needed on account creation' ); + + $this->assertNotEquals( + $info[AuthManager::ACTION_LOGIN]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from login' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from create' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['retype']['label'], + $info[AuthManager::ACTION_CHANGE]['retype']['label'], + 'Retype field for change is differentiated from create' + ); + } + + public function provideLoadFromSubmission() { + $domainList = [ 'domainList' => [ 'd1', 'd2' ] ]; + return [ + 'Empty request, login' => [ + [ AuthManager::ACTION_LOGIN ], + [], + false, + ], + 'Empty request, change' => [ + [ AuthManager::ACTION_CHANGE ], + [], + false, + ], + 'Empty request, remove' => [ + [ AuthManager::ACTION_REMOVE ], + [], + false, + ], + 'Username + password, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar' ], + false, + ], + 'Username + password + domain, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd1' ], + $data + [ 'action' => AuthManager::ACTION_LOGIN ] + $domainList, + ], + 'Username + password + bad domain, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd5' ], + false, + ], + 'Username + password + domain, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd1' ], + false, + ], + 'Username + password + domain + retype' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => 'baz', 'domain' => 'd1' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ] + + $domainList, + ], + 'Username empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => '', 'password' => 'Bar', 'domain' => 'd1' ], + false, + ], + 'Username empty, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => '', 'password' => 'Bar', 'retype' => 'baz', 'domain' => 'd1' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ] + + $domainList, + ], + 'Password empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '', 'domain' => 'd1' ], + false, + ], + 'Password empty, login, with retype' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '', 'retype' => 'baz', 'domain' => 'd1' ], + false, + ], + 'Retype empty' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => '', 'domain' => 'd1' ], + false, + ], + ]; + } + + public function testDescribeCredentials() { + $req = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] ); + $req->action = AuthManager::ACTION_LOGIN; + $req->username = 'UTSysop'; + $req->domain = 'd2'; + $ret = $req->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( \Message::class, $ret['provider'] ); + $this->assertSame( 'authmanager-provider-password-domain', $ret['provider']->getKey() ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( \Message::class, $ret['account'] ); + $this->assertSame( 'authmanager-account-password-domain', $ret['account']->getKey() ); + $this->assertSame( [ 'UTSysop', 'd2' ], $ret['account']->getParams() ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php new file mode 100644 index 00000000..9bcab777 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php @@ -0,0 +1,57 @@ +<?php + +namespace MediaWiki\Auth; + +use Wikimedia\TestingAccessWrapper; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\RememberMeAuthenticationRequest + */ +class RememberMeAuthenticationRequestTest extends AuthenticationRequestTestCase { + + public static function provideGetFieldInfo() { + return [ + [ [ 1 ] ], + [ [ null ] ], + ]; + } + + public function testGetFieldInfo_2() { + $req = new RememberMeAuthenticationRequest(); + $reqWrapper = TestingAccessWrapper::newFromObject( $req ); + + $reqWrapper->expiration = 30 * 24 * 3600; + $this->assertNotEmpty( $req->getFieldInfo() ); + + $reqWrapper->expiration = null; + $this->assertEmpty( $req->getFieldInfo() ); + } + + protected function getInstance( array $args = [] ) { + $req = new RememberMeAuthenticationRequest(); + $reqWrapper = TestingAccessWrapper::newFromObject( $req ); + $reqWrapper->expiration = $args[0]; + return $req; + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [ 30 * 24 * 3600 ], + [], + [ 'expiration' => 30 * 24 * 3600, 'rememberMe' => false ] + ], + 'RememberMe present' => [ + [ 30 * 24 * 3600 ], + [ 'rememberMe' => '' ], + [ 'expiration' => 30 * 24 * 3600, 'rememberMe' => true ] + ], + 'RememberMe present but session provider cannot remember' => [ + [ null ], + [ 'rememberMe' => '' ], + false + ], + ]; + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php new file mode 100644 index 00000000..f454a96a --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php @@ -0,0 +1,310 @@ +<?php + +namespace MediaWiki\Auth; + +use Wikimedia\TestingAccessWrapper; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\ResetPasswordSecondaryAuthenticationProvider + */ +class ResetPasswordSecondaryAuthenticationProviderTest extends \MediaWikiTestCase { + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + */ + public function testGetAuthenticationRequests( $action, $response ) { + $provider = new ResetPasswordSecondaryAuthenticationProvider(); + + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + public function testBasics() { + $user = \User::newFromName( 'UTSysop' ); + $user2 = new \User; + $obj = new \stdClass; + $reqs = [ new \stdClass ]; + + $mb = $this->getMockBuilder( ResetPasswordSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'tryReset' ] ); + + $methods = [ + 'beginSecondaryAuthentication' => [ $user, $reqs ], + 'continueSecondaryAuthentication' => [ $user, $reqs ], + 'beginSecondaryAccountCreation' => [ $user, $user2, $reqs ], + 'continueSecondaryAccountCreation' => [ $user, $user2, $reqs ], + ]; + foreach ( $methods as $method => $args ) { + $mock = $mb->getMock(); + $mock->expects( $this->once() )->method( 'tryReset' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( $reqs ) ) + ->will( $this->returnValue( $obj ) ); + $this->assertSame( $obj, call_user_func_array( [ $mock, $method ], $args ) ); + } + } + + public function testTryReset() { + $user = \User::newFromName( 'UTSysop' ); + + $provider = $this->getMockBuilder( + ResetPasswordSecondaryAuthenticationProvider::class + ) + ->setMethods( [ + 'providerAllowsAuthenticationDataChange', 'providerChangeAuthenticationData' + ] ) + ->getMock(); + $provider->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + return $req->allow; + } ) ); + $provider->expects( $this->any() )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + $req->done = true; + } ) ); + $config = new \HashConfig( [ + 'AuthManagerConfig' => [ + 'preauth' => [], + 'primaryauth' => [], + 'secondaryauth' => [ + [ 'factory' => function () use ( $provider ) { + return $provider; + } ], + ], + ], + ] ); + $manager = new AuthManager( new \FauxRequest, $config ); + $provider->setManager( $manager ); + $provider = TestingAccessWrapper::newFromObject( $provider ); + + $msg = wfMessage( 'foo' ); + $skipReq = new ButtonAuthenticationRequest( + 'skipReset', + wfMessage( 'authprovider-resetpass-skip-label' ), + wfMessage( 'authprovider-resetpass-skip-help' ) + ); + $passReq = new PasswordAuthenticationRequest(); + $passReq->action = AuthManager::ACTION_CHANGE; + $passReq->password = 'Foo'; + $passReq->retype = 'Bar'; + $passReq->allow = \StatusValue::newGood(); + $passReq->done = false; + + $passReq2 = $this->getMockBuilder( PasswordAuthenticationRequest::class ) + ->enableProxyingToOriginalMethods() + ->getMock(); + $passReq2->action = AuthManager::ACTION_CHANGE; + $passReq2->password = 'Foo'; + $passReq2->retype = 'Foo'; + $passReq2->allow = \StatusValue::newGood(); + $passReq2->done = false; + + $passReq3 = new PasswordAuthenticationRequest(); + $passReq3->action = AuthManager::ACTION_LOGIN; + $passReq3->password = 'Foo'; + $passReq3->retype = 'Foo'; + $passReq3->allow = \StatusValue::newGood(); + $passReq3->done = false; + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->tryReset( $user, [] ) + ); + + $manager->setAuthenticationSessionData( 'reset-pass', 'foo' ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', (object)[] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass msg is missing', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => 'foo', + ] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass msg is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + ] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass hard is missing', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + 'req' => 'foo', + ] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass req is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq3, + ] ); + try { + $provider->tryReset( $user, [ $passReq ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass req is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + ] ); + $res = $provider->tryReset( $user, [] ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq, + ] ); + $res = $provider->tryReset( $user, [] ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 2, $res->neededRequests ); + $expectedPassReq = clone $passReq; + $expectedPassReq->required = AuthenticationRequest::OPTIONAL; + $this->assertEquals( $expectedPassReq, $res->neededRequests[0] ); + $this->assertEquals( $skipReq, $res->neededRequests[1] ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->retype = 'Bad'; + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq, + ] ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->retype = 'Bad'; + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + ] ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertSame( 'badretype', $res->message->getKey() ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + ] ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq3 ] ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->retype = $passReq->password; + $passReq->allow = \StatusValue::newFatal( 'arbitrary-fail' ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertSame( 'arbitrary-fail', $res->message->getKey() ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->allow = \StatusValue::newGood(); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertTrue( $passReq->done ); + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq2, + ] ); + $res = $provider->tryReset( $user, [ $passReq2 ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertTrue( $passReq2->done ); + + $passReq->done = false; + $passReq2->done = false; + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq2, + ] ); + $res = $provider->tryReset( $user, [ $passReq ] ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 2, $res->neededRequests ); + $expectedPassReq = clone $passReq2; + $expectedPassReq->required = AuthenticationRequest::OPTIONAL; + $this->assertEquals( $expectedPassReq, $res->neededRequests[0] ); + $this->assertEquals( $skipReq, $res->neededRequests[1] ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + $this->assertFalse( $passReq2->done ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php new file mode 100644 index 00000000..ab4a174e --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php @@ -0,0 +1,79 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\TemporaryPasswordAuthenticationRequest + */ +class TemporaryPasswordAuthenticationRequestTest extends AuthenticationRequestTestCase { + + protected function getInstance( array $args = [] ) { + $ret = new TemporaryPasswordAuthenticationRequest; + $ret->action = $args[0]; + return $ret; + } + + public static function provideGetFieldInfo() { + return [ + [ [ AuthManager::ACTION_CREATE ] ], + [ [ AuthManager::ACTION_CHANGE ] ], + [ [ AuthManager::ACTION_REMOVE ] ], + ]; + } + + public function testNewRandom() { + global $wgPasswordPolicy; + + $this->stashMwGlobals( 'wgPasswordPolicy' ); + $wgPasswordPolicy['policies']['default'] += [ + 'MinimalPasswordLength' => 1, + 'MinimalPasswordLengthToLogin' => 1, + ]; + + $ret1 = TemporaryPasswordAuthenticationRequest::newRandom(); + $ret2 = TemporaryPasswordAuthenticationRequest::newRandom(); + $this->assertNotSame( '', $ret1->password ); + $this->assertNotSame( '', $ret2->password ); + $this->assertNotSame( $ret1->password, $ret2->password ); + } + + public function testNewInvalid() { + $ret = TemporaryPasswordAuthenticationRequest::newInvalid(); + $this->assertNull( $ret->password ); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [ AuthManager::ACTION_REMOVE ], + [], + false, + ], + 'Create, empty request' => [ + [ AuthManager::ACTION_CREATE ], + [], + false, + ], + 'Create, mailpassword set' => [ + [ AuthManager::ACTION_CREATE ], + [ 'mailpassword' => 1 ], + [ 'mailpassword' => true, 'action' => AuthManager::ACTION_CREATE ], + ], + ]; + } + + public function testDescribeCredentials() { + $req = new TemporaryPasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_LOGIN; + $req->username = 'UTSysop'; + $ret = $req->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( \Message::class, $ret['provider'] ); + $this->assertSame( 'authmanager-provider-temporarypassword', $ret['provider']->getKey() ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( \Message::class, $ret['account'] ); + $this->assertSame( [ 'UTSysop' ], $ret['account']->getParams() ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php new file mode 100644 index 00000000..1708f1c0 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php @@ -0,0 +1,720 @@ +<?php + +namespace MediaWiki\Auth; + +use MediaWiki\MediaWikiServices; +use Wikimedia\ScopedCallback; +use Wikimedia\TestingAccessWrapper; + +/** + * @group AuthManager + * @group Database + * @covers MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider + */ +class TemporaryPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestCase { + + private $manager = null; + private $config = null; + private $validity = null; + + /** + * Get an instance of the provider + * + * $provider->checkPasswordValidity is mocked to return $this->validity, + * because we don't need to test that here. + * + * @param array $params + * @return TemporaryPasswordPrimaryAuthenticationProvider + */ + protected function getProvider( $params = [] ) { + if ( !$this->config ) { + $this->config = new \HashConfig( [ + 'EmailEnabled' => true, + ] ); + } + $config = new \MultiConfig( [ + $this->config, + MediaWikiServices::getInstance()->getMainConfig() + ] ); + + if ( !$this->manager ) { + $this->manager = new AuthManager( new \FauxRequest(), $config ); + } + $this->validity = \Status::newGood(); + + $mockedMethods[] = 'checkPasswordValidity'; + $provider = $this->getMockBuilder( TemporaryPasswordPrimaryAuthenticationProvider::class ) + ->setMethods( $mockedMethods ) + ->setConstructorArgs( [ $params ] ) + ->getMock(); + $provider->expects( $this->any() )->method( 'checkPasswordValidity' ) + ->will( $this->returnCallback( function () { + return $this->validity; + } ) ); + $provider->setConfig( $config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $this->manager ); + + return $provider; + } + + protected function hookMailer( $func = null ) { + \Hooks::clear( 'AlternateUserMailer' ); + if ( $func ) { + \Hooks::register( 'AlternateUserMailer', $func ); + // Safety + \Hooks::register( 'AlternateUserMailer', function () { + return false; + } ); + } else { + \Hooks::register( 'AlternateUserMailer', function () { + $this->fail( 'AlternateUserMailer hook called unexpectedly' ); + return false; + } ); + } + + return new ScopedCallback( function () { + \Hooks::clear( 'AlternateUserMailer' ); + \Hooks::register( 'AlternateUserMailer', function () { + return false; + } ); + } ); + } + + public function testBasics() { + $provider = new TemporaryPasswordPrimaryAuthenticationProvider(); + + $this->assertSame( + PrimaryAuthenticationProvider::TYPE_CREATE, + $provider->accountCreationType() + ); + + $this->assertTrue( $provider->testUserExists( 'UTSysop' ) ); + $this->assertTrue( $provider->testUserExists( 'uTSysop' ) ); + $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) ); + $this->assertFalse( $provider->testUserExists( '<invalid>' ) ); + + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_CHANGE; + $req->username = '<invalid>'; + $provider->providerChangeAuthenticationData( $req ); + } + + public function testConfig() { + $config = new \HashConfig( [ + 'EnableEmail' => false, + 'NewPasswordExpiry' => 100, + 'PasswordReminderResendTime' => 101, + ] ); + + $p = TestingAccessWrapper::newFromObject( new TemporaryPasswordPrimaryAuthenticationProvider() ); + $p->setConfig( $config ); + $this->assertSame( false, $p->emailEnabled ); + $this->assertSame( 100, $p->newPasswordExpiry ); + $this->assertSame( 101, $p->passwordReminderResendTime ); + + $p = TestingAccessWrapper::newFromObject( new TemporaryPasswordPrimaryAuthenticationProvider( [ + 'emailEnabled' => true, + 'newPasswordExpiry' => 42, + 'passwordReminderResendTime' => 43, + ] ) ); + $p->setConfig( $config ); + $this->assertSame( true, $p->emailEnabled ); + $this->assertSame( 42, $p->newPasswordExpiry ); + $this->assertSame( 43, $p->passwordReminderResendTime ); + } + + public function testTestUserCanAuthenticate() { + $user = self::getMutableTestUser()->getUser(); + + $dbw = wfGetDB( DB_MASTER ); + + $passwordFactory = new \PasswordFactory(); + $passwordFactory->init( \RequestContext::getMain()->getConfig() ); + // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only + $passwordFactory->setDefaultType( 'A' ); + $pwhash = $passwordFactory->newFromPlaintext( 'password' )->toString(); + + $provider = $this->getProvider(); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + + $this->assertFalse( $provider->testUserCanAuthenticate( '<invalid>' ) ); + $this->assertFalse( $provider->testUserCanAuthenticate( 'DoesNotExist' ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => \PasswordFactory::newInvalidPassword()->toString(), + 'user_newpass_time' => null, + ], + [ 'user_id' => $user->getId() ] + ); + $this->assertFalse( $provider->testUserCanAuthenticate( $user->getName() ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => $pwhash, + 'user_newpass_time' => null, + ], + [ 'user_id' => $user->getId() ] + ); + $this->assertTrue( $provider->testUserCanAuthenticate( $user->getName() ) ); + $this->assertTrue( $provider->testUserCanAuthenticate( lcfirst( $user->getName() ) ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => $pwhash, + 'user_newpass_time' => $dbw->timestamp( time() - 10 ), + ], + [ 'user_id' => $user->getId() ] + ); + $providerPriv->newPasswordExpiry = 100; + $this->assertTrue( $provider->testUserCanAuthenticate( $user->getName() ) ); + $providerPriv->newPasswordExpiry = 1; + $this->assertFalse( $provider->testUserCanAuthenticate( $user->getName() ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => \PasswordFactory::newInvalidPassword()->toString(), + 'user_newpass_time' => null, + ], + [ 'user_id' => $user->getId() ] + ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $options + * @param array $expected + */ + public function testGetAuthenticationRequests( $action, $options, $expected ) { + $actual = $this->getProvider()->getAuthenticationRequests( $action, $options ); + foreach ( $actual as $req ) { + if ( $req instanceof TemporaryPasswordAuthenticationRequest && $req->password !== null ) { + $req->password = 'random'; + } + } + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetAuthenticationRequests() { + $anon = [ 'username' => null ]; + $loggedIn = [ 'username' => 'UTSysop' ]; + + return [ + [ AuthManager::ACTION_LOGIN, $anon, [ + new PasswordAuthenticationRequest + ] ], + [ AuthManager::ACTION_LOGIN, $loggedIn, [ + new PasswordAuthenticationRequest + ] ], + [ AuthManager::ACTION_CREATE, $anon, [] ], + [ AuthManager::ACTION_CREATE, $loggedIn, [ + new TemporaryPasswordAuthenticationRequest( 'random' ) + ] ], + [ AuthManager::ACTION_LINK, $anon, [] ], + [ AuthManager::ACTION_LINK, $loggedIn, [] ], + [ AuthManager::ACTION_CHANGE, $anon, [ + new TemporaryPasswordAuthenticationRequest( 'random' ) + ] ], + [ AuthManager::ACTION_CHANGE, $loggedIn, [ + new TemporaryPasswordAuthenticationRequest( 'random' ) + ] ], + [ AuthManager::ACTION_REMOVE, $anon, [ + new TemporaryPasswordAuthenticationRequest + ] ], + [ AuthManager::ACTION_REMOVE, $loggedIn, [ + new TemporaryPasswordAuthenticationRequest + ] ], + ]; + } + + public function testAuthentication() { + $user = self::getMutableTestUser()->getUser(); + + $password = 'TemporaryPassword'; + $hash = ':A:' . md5( $password ); + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'user', + [ 'user_newpassword' => $hash, 'user_newpass_time' => $dbw->timestamp( time() - 10 ) ], + [ 'user_id' => $user->getId() ] + ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + + $providerPriv->newPasswordExpiry = 100; + + // General failures + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = '<invalid>'; + $req->password = 'WhoCares'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = 'DoesNotExist'; + $req->password = 'DoesNotExist'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + // Validation failure + $req->username = $user->getName(); + $req->password = $password; + $this->validity = \Status::newFatal( 'arbitrary-failure' ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'arbitrary-failure', + $ret->message->getKey() + ); + + // Successful auth + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $this->assertEquals( + AuthenticationResponse::newPass( $user->getName() ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $req->username = lcfirst( $user->getName() ); + $this->assertEquals( + AuthenticationResponse::newPass( $user->getName() ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + $req->username = $user->getName(); + + // Expired password + $providerPriv->newPasswordExpiry = 1; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + // Bad password + $providerPriv->newPasswordExpiry = 100; + $this->validity = \Status::newGood(); + $req->password = 'Wrong'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + } + + /** + * @dataProvider provideProviderAllowsAuthenticationDataChange + * @param string $type + * @param string $user + * @param \Status $validity Result of the password validity check + * @param \StatusValue $expect1 Expected result with $checkData = false + * @param \StatusValue $expect2 Expected result with $checkData = true + */ + public function testProviderAllowsAuthenticationDataChange( $type, $user, \Status $validity, + \StatusValue $expect1, \StatusValue $expect2 + ) { + if ( $type === PasswordAuthenticationRequest::class || + $type === TemporaryPasswordAuthenticationRequest::class + ) { + $req = new $type(); + } else { + $req = $this->createMock( $type ); + } + $req->action = AuthManager::ACTION_CHANGE; + $req->username = $user; + $req->password = 'NewPassword'; + + $provider = $this->getProvider(); + $this->validity = $validity; + $this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) ); + $this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) ); + } + + public static function provideProviderAllowsAuthenticationDataChange() { + $err = \StatusValue::newGood(); + $err->error( 'arbitrary-warning' ); + + return [ + [ AuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ TemporaryPasswordAuthenticationRequest::class, 'uTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop', \Status::wrap( $err ), + \StatusValue::newGood(), $err ], + [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop', + \Status::newFatal( 'arbitrary-error' ), \StatusValue::newGood(), + \StatusValue::newFatal( 'arbitrary-error' ) ], + [ TemporaryPasswordAuthenticationRequest::class, 'DoesNotExist', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ], + [ TemporaryPasswordAuthenticationRequest::class, '<invalid>', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ], + ]; + } + + /** + * @dataProvider provideProviderChangeAuthenticationData + * @param string $user + * @param string $type + * @param bool $changed + */ + public function testProviderChangeAuthenticationData( $user, $type, $changed ) { + $cuser = ucfirst( $user ); + $oldpass = 'OldTempPassword'; + $newpass = 'NewTempPassword'; + + $dbw = wfGetDB( DB_MASTER ); + $oldHash = $dbw->selectField( 'user', 'user_newpassword', [ 'user_name' => $cuser ] ); + $cb = new ScopedCallback( function () use ( $dbw, $cuser, $oldHash ) { + $dbw->update( 'user', [ 'user_newpassword' => $oldHash ], [ 'user_name' => $cuser ] ); + } ); + + $hash = ':A:' . md5( $oldpass ); + $dbw->update( + 'user', + [ 'user_newpassword' => $hash, 'user_newpass_time' => $dbw->timestamp( time() + 10 ) ], + [ 'user_name' => $cuser ] + ); + + $provider = $this->getProvider(); + + // Sanity check + $loginReq = new PasswordAuthenticationRequest(); + $loginReq->action = AuthManager::ACTION_CHANGE; + $loginReq->username = $user; + $loginReq->password = $oldpass; + $loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ]; + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $provider->beginPrimaryAuthentication( $loginReqs ), + 'Sanity check' + ); + + if ( $type === PasswordAuthenticationRequest::class || + $type === TemporaryPasswordAuthenticationRequest::class + ) { + $changeReq = new $type(); + } else { + $changeReq = $this->createMock( $type ); + } + $changeReq->action = AuthManager::ACTION_CHANGE; + $changeReq->username = $user; + $changeReq->password = $newpass; + $resetMailer = $this->hookMailer(); + $provider->providerChangeAuthenticationData( $changeReq ); + ScopedCallback::consume( $resetMailer ); + + $loginReq->password = $oldpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'old password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'old password should fail' + ); + + $loginReq->password = $newpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + if ( $changed ) { + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $ret, + 'new password should pass' + ); + $this->assertNotNull( + $dbw->selectField( 'user', 'user_newpass_time', [ 'user_name' => $cuser ] ) + ); + } else { + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'new password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'new password should fail' + ); + $this->assertNull( + $dbw->selectField( 'user', 'user_newpass_time', [ 'user_name' => $cuser ] ) + ); + } + } + + public static function provideProviderChangeAuthenticationData() { + return [ + [ 'UTSysop', AuthenticationRequest::class, false ], + [ 'UTSysop', PasswordAuthenticationRequest::class, false ], + [ 'UTSysop', TemporaryPasswordAuthenticationRequest::class, true ], + ]; + } + + public function testProviderChangeAuthenticationDataEmail() { + $user = self::getMutableTestUser()->getUser(); + + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'user', + [ 'user_newpass_time' => $dbw->timestamp( time() - 5 * 3600 ) ], + [ 'user_id' => $user->getId() ] + ); + + $req = TemporaryPasswordAuthenticationRequest::newRandom(); + $req->username = $user->getName(); + $req->mailpassword = true; + + $provider = $this->getProvider( [ 'emailEnabled' => false ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-emaildisabled' ), $status ); + + $provider = $this->getProvider( [ 'passwordReminderResendTime' => 10 ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'throttled-mailpassword', 10 ), $status ); + + $provider = $this->getProvider( [ 'passwordReminderResendTime' => 3 ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertFalse( $status->hasMessage( 'throttled-mailpassword' ) ); + + $dbw->update( + 'user', + [ 'user_newpass_time' => $dbw->timestamp( time() + 5 * 3600 ) ], + [ 'user_id' => $user->getId() ] + ); + $provider = $this->getProvider( [ 'passwordReminderResendTime' => 0 ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertFalse( $status->hasMessage( 'throttled-mailpassword' ) ); + + $req->caller = null; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nocaller' ), $status ); + + $req->caller = '127.0.0.256'; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nosuchcaller', '127.0.0.256' ), + $status ); + + $req->caller = '<Invalid>'; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nosuchcaller', '<Invalid>' ), + $status ); + + $req->caller = '127.0.0.1'; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newGood(), $status ); + + $req->caller = $user->getName(); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newGood(), $status ); + + $mailed = false; + $resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body ) + use ( &$mailed, $req, $user ) + { + $mailed = true; + $this->assertSame( $user->getEmail(), $to[0]->address ); + $this->assertContains( $req->password, $body ); + return false; + } ); + $provider->providerChangeAuthenticationData( $req ); + ScopedCallback::consume( $resetMailer ); + $this->assertTrue( $mailed ); + + $priv = TestingAccessWrapper::newFromObject( $provider ); + $req->username = '<invalid>'; + $status = $priv->sendPasswordResetEmail( $req ); + $this->assertEquals( \Status::newFatal( 'noname' ), $status ); + } + + public function testTestForAccountCreation() { + $user = \User::newFromName( 'foo' ); + $req = new TemporaryPasswordAuthenticationRequest(); + $req->username = 'Foo'; + $req->password = 'Bar'; + $reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ), + 'No password request' + ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, validated' + ); + + $this->validity->error( 'arbitrary warning' ); + $expect = \StatusValue::newGood(); + $expect->error( 'arbitrary warning' ); + $this->assertEquals( + $expect, + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, not validated' + ); + } + + public function testAccountCreation() { + $resetMailer = $this->hookMailer(); + + $user = \User::newFromName( 'Foo' ); + + $req = new TemporaryPasswordAuthenticationRequest(); + $reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ]; + + $authreq = new PasswordAuthenticationRequest(); + $authreq->action = AuthManager::ACTION_CREATE; + $authreqs = [ PasswordAuthenticationRequest::class => $authreq ]; + + $provider = $this->getProvider(); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $expect = AuthenticationResponse::newPass( 'Foo' ); + $expect->createRequest = clone $req; + $expect->createRequest->username = 'Foo'; + $this->assertEquals( $expect, $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'no-email' ) ); + + $user = self::getMutableTestUser()->getUser(); + $req->username = $authreq->username = $user->getName(); + $req->password = $authreq->password = 'NewPassword'; + $expect = AuthenticationResponse::newPass( $user->getName() ); + $expect->createRequest = $req; + + $res2 = $provider->beginPrimaryAccountCreation( $user, $user, $reqs ); + $this->assertEquals( $expect, $res2, 'Sanity check' ); + + $ret = $provider->beginPrimaryAuthentication( $authreqs ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status, 'sanity check' ); + + $this->assertSame( null, $provider->finishAccountCreation( $user, $user, $res2 ) ); + + $ret = $provider->beginPrimaryAuthentication( $authreqs ); + $this->assertEquals( AuthenticationResponse::PASS, $ret->status, 'new password is set' ); + } + + public function testAccountCreationEmail() { + $creator = \User::newFromName( 'Foo' ); + + $user = self::getMutableTestUser()->getUser(); + $user->setEmail( null ); + + $req = TemporaryPasswordAuthenticationRequest::newRandom(); + $req->username = $user->getName(); + $req->mailpassword = true; + + $provider = $this->getProvider( [ 'emailEnabled' => false ] ); + $status = $provider->testForAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( \StatusValue::newFatal( 'emaildisabled' ), $status ); + + $provider = $this->getProvider( [ 'emailEnabled' => true ] ); + $status = $provider->testForAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( \StatusValue::newFatal( 'noemailcreate' ), $status ); + + $user->setEmail( 'test@localhost.localdomain' ); + $status = $provider->testForAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( \StatusValue::newGood(), $status ); + + $mailed = false; + $resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body ) + use ( &$mailed, $req ) + { + $mailed = true; + $this->assertSame( 'test@localhost.localdomain', $to[0]->address ); + $this->assertContains( $req->password, $body ); + return false; + } ); + + $expect = AuthenticationResponse::newPass( $user->getName() ); + $expect->createRequest = clone $req; + $expect->createRequest->username = $user->getName(); + $res = $provider->beginPrimaryAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( $expect, $res ); + $this->assertTrue( $this->manager->getAuthenticationSessionData( 'no-email' ) ); + $this->assertFalse( $mailed ); + + $this->assertSame( 'byemail', $provider->finishAccountCreation( $user, $creator, $res ) ); + $this->assertTrue( $mailed ); + + ScopedCallback::consume( $resetMailer ); + $this->assertTrue( $mailed ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php new file mode 100644 index 00000000..d03b1515 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php @@ -0,0 +1,236 @@ +<?php + +namespace MediaWiki\Auth; + +use Wikimedia\TestingAccessWrapper; + +/** + * @group AuthManager + * @group Database + * @covers MediaWiki\Auth\ThrottlePreAuthenticationProvider + */ +class ThrottlePreAuthenticationProviderTest extends \MediaWikiTestCase { + public function testConstructor() { + $provider = new ThrottlePreAuthenticationProvider(); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + $config = new \HashConfig( [ + 'AccountCreationThrottle' => [ [ + 'count' => 123, + 'seconds' => 86400, + ] ], + 'PasswordAttemptThrottle' => [ [ + 'count' => 5, + 'seconds' => 300, + ] ], + ] ); + $provider->setConfig( $config ); + $this->assertSame( [ + 'accountCreationThrottle' => [ [ 'count' => 123, 'seconds' => 86400 ] ], + 'passwordAttemptThrottle' => [ [ 'count' => 5, 'seconds' => 300 ] ] + ], $providerPriv->throttleSettings ); + $accountCreationThrottle = TestingAccessWrapper::newFromObject( + $providerPriv->accountCreationThrottle ); + $this->assertSame( [ [ 'count' => 123, 'seconds' => 86400 ] ], + $accountCreationThrottle->conditions ); + $passwordAttemptThrottle = TestingAccessWrapper::newFromObject( + $providerPriv->passwordAttemptThrottle ); + $this->assertSame( [ [ 'count' => 5, 'seconds' => 300 ] ], + $passwordAttemptThrottle->conditions ); + + $provider = new ThrottlePreAuthenticationProvider( [ + 'accountCreationThrottle' => [ [ 'count' => 43, 'seconds' => 10000 ] ], + 'passwordAttemptThrottle' => [ [ 'count' => 11, 'seconds' => 100 ] ], + ] ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + $config = new \HashConfig( [ + 'AccountCreationThrottle' => [ [ + 'count' => 123, + 'seconds' => 86400, + ] ], + 'PasswordAttemptThrottle' => [ [ + 'count' => 5, + 'seconds' => 300, + ] ], + ] ); + $provider->setConfig( $config ); + $this->assertSame( [ + 'accountCreationThrottle' => [ [ 'count' => 43, 'seconds' => 10000 ] ], + 'passwordAttemptThrottle' => [ [ 'count' => 11, 'seconds' => 100 ] ], + ], $providerPriv->throttleSettings ); + + $cache = new \HashBagOStuff(); + $provider = new ThrottlePreAuthenticationProvider( [ 'cache' => $cache ] ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => [ [ 'count' => 1, 'seconds' => 1 ] ], + 'PasswordAttemptThrottle' => [ [ 'count' => 1, 'seconds' => 1 ] ], + ] ) ); + $accountCreationThrottle = TestingAccessWrapper::newFromObject( + $providerPriv->accountCreationThrottle ); + $this->assertSame( $cache, $accountCreationThrottle->cache ); + $passwordAttemptThrottle = TestingAccessWrapper::newFromObject( + $providerPriv->passwordAttemptThrottle ); + $this->assertSame( $cache, $passwordAttemptThrottle->cache ); + } + + public function testDisabled() { + $provider = new ThrottlePreAuthenticationProvider( [ + 'accountCreationThrottle' => [], + 'passwordAttemptThrottle' => [], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( + \User::newFromName( 'Created' ), + \User::newFromName( 'Creator' ), + [] + ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAuthentication( [] ) + ); + } + + /** + * @dataProvider provideTestForAccountCreation + * @param string $creatorname + * @param bool $succeed + * @param bool $hook + */ + public function testTestForAccountCreation( $creatorname, $succeed, $hook ) { + $provider = new ThrottlePreAuthenticationProvider( [ + 'accountCreationThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + + $user = \User::newFromName( 'RandomUser' ); + $creator = \User::newFromName( $creatorname ); + if ( $hook ) { + $mock = $this->getMockBuilder( stdClass::class ) + ->setMethods( [ 'onExemptFromAccountCreationThrottle' ] ) + ->getMock(); + $mock->expects( $this->any() )->method( 'onExemptFromAccountCreationThrottle' ) + ->will( $this->returnValue( false ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'ExemptFromAccountCreationThrottle' => [ $mock ], + ] ); + } + + $this->assertEquals( + true, + $provider->testForAccountCreation( $user, $creator, [] )->isOK(), + 'attempt #1' + ); + $this->assertEquals( + true, + $provider->testForAccountCreation( $user, $creator, [] )->isOK(), + 'attempt #2' + ); + $this->assertEquals( + $succeed ? true : false, + $provider->testForAccountCreation( $user, $creator, [] )->isOK(), + 'attempt #3' + ); + } + + public static function provideTestForAccountCreation() { + return [ + 'Normal user' => [ 'NormalUser', false, false ], + 'Sysop' => [ 'UTSysop', true, false ], + 'Normal user with hook' => [ 'NormalUser', true, true ], + ]; + } + + public function testTestForAuthentication() { + $provider = new ThrottlePreAuthenticationProvider( [ + 'passwordAttemptThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + + $req = new UsernameAuthenticationRequest; + $req->username = 'SomeUser'; + for ( $i = 1; $i <= 3; $i++ ) { + $status = $provider->testForAuthentication( [ $req ] ); + $this->assertEquals( $i < 3, $status->isGood(), "attempt #$i" ); + } + $this->assertCount( 1, $status->getErrors() ); + $msg = new \Message( $status->getErrors()[0]['message'], $status->getErrors()[0]['params'] ); + $this->assertEquals( 'login-throttled', $msg->getKey() ); + + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newFail( wfMessage( 'foo' ) ) ); + $this->assertFalse( $provider->testForAuthentication( [ $req ] )->isGood(), 'after FAIL' ); + + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newPass() ); + $this->assertTrue( $provider->testForAuthentication( [ $req ] )->isGood(), 'after PASS' ); + + $req1 = new UsernameAuthenticationRequest; + $req1->username = 'foo'; + $req2 = new UsernameAuthenticationRequest; + $req2->username = 'bar'; + $this->assertTrue( $provider->testForAuthentication( [ $req1, $req2 ] )->isGood() ); + + $req = new UsernameAuthenticationRequest; + $req->username = 'Some user'; + $provider->testForAuthentication( [ $req ] ); + $req->username = 'Some_user'; + $provider->testForAuthentication( [ $req ] ); + $req->username = 'some user'; + $status = $provider->testForAuthentication( [ $req ] ); + $this->assertFalse( $status->isGood(), 'denormalized usernames are normalized' ); + } + + public function testPostAuthentication() { + $provider = new ThrottlePreAuthenticationProvider( [ + 'passwordAttemptThrottle' => [], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \TestLogger ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newPass() ); + + $provider = new ThrottlePreAuthenticationProvider( [ + 'passwordAttemptThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ], + 'cache' => new \HashBagOStuff(), + ] ); + $logger = new \TestLogger( true ); + $provider->setLogger( $logger ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newPass() ); + $this->assertSame( [ + [ \Psr\Log\LogLevel::INFO, 'throttler data not found for {user}' ], + ], $logger->getBuffer() ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/ThrottlerTest.php b/www/wiki/tests/phpunit/includes/auth/ThrottlerTest.php new file mode 100644 index 00000000..f963ad9c --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/ThrottlerTest.php @@ -0,0 +1,238 @@ +<?php + +namespace MediaWiki\Auth; + +use BagOStuff; +use HashBagOStuff; +use Psr\Log\AbstractLogger; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Wikimedia\TestingAccessWrapper; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\Throttler + */ +class ThrottlerTest extends \MediaWikiTestCase { + public function testConstructor() { + $cache = new \HashBagOStuff(); + $logger = $this->getMockBuilder( AbstractLogger::class ) + ->setMethods( [ 'log' ] ) + ->getMockForAbstractClass(); + + $throttler = new Throttler( + [ [ 'count' => 123, 'seconds' => 456 ] ], + [ 'type' => 'foo', 'cache' => $cache ] + ); + $throttler->setLogger( $logger ); + $throttlerPriv = TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( [ [ 'count' => 123, 'seconds' => 456 ] ], $throttlerPriv->conditions ); + $this->assertSame( 'foo', $throttlerPriv->type ); + $this->assertSame( $cache, $throttlerPriv->cache ); + $this->assertSame( $logger, $throttlerPriv->logger ); + + $throttler = new Throttler( [ [ 'count' => 123, 'seconds' => 456 ] ] ); + $throttler->setLogger( new NullLogger() ); + $throttlerPriv = TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( [ [ 'count' => 123, 'seconds' => 456 ] ], $throttlerPriv->conditions ); + $this->assertSame( 'custom', $throttlerPriv->type ); + $this->assertInstanceOf( BagOStuff::class, $throttlerPriv->cache ); + $this->assertInstanceOf( LoggerInterface::class, $throttlerPriv->logger ); + + $this->setMwGlobals( [ 'wgPasswordAttemptThrottle' => [ [ 'count' => 321, + 'seconds' => 654 ] ] ] ); + $throttler = new Throttler(); + $throttler->setLogger( new NullLogger() ); + $throttlerPriv = TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( [ [ 'count' => 321, 'seconds' => 654 ] ], $throttlerPriv->conditions ); + $this->assertSame( 'password', $throttlerPriv->type ); + $this->assertInstanceOf( BagOStuff::class, $throttlerPriv->cache ); + $this->assertInstanceOf( LoggerInterface::class, $throttlerPriv->logger ); + + try { + new Throttler( [], [ 'foo' => 1, 'bar' => 2, 'baz' => 3 ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'unrecognized parameters: foo, bar, baz', $ex->getMessage() ); + } + } + + /** + * @dataProvider provideNormalizeThrottleConditions + */ + public function testNormalizeThrottleConditions( $condition, $normalized ) { + $throttler = new Throttler( $condition ); + $throttler->setLogger( new NullLogger() ); + $throttlerPriv = TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( $normalized, $throttlerPriv->conditions ); + } + + public function provideNormalizeThrottleConditions() { + return [ + [ + [], + [], + ], + [ + [ 'count' => 1, 'seconds' => 2 ], + [ [ 'count' => 1, 'seconds' => 2 ] ], + ], + [ + [ [ 'count' => 1, 'seconds' => 2 ], [ 'count' => 2, 'seconds' => 3 ] ], + [ [ 'count' => 1, 'seconds' => 2 ], [ 'count' => 2, 'seconds' => 3 ] ], + ], + ]; + } + + public function testNormalizeThrottleConditions2() { + $priv = TestingAccessWrapper::newFromClass( Throttler::class ); + $this->assertSame( [], $priv->normalizeThrottleConditions( null ) ); + $this->assertSame( [], $priv->normalizeThrottleConditions( 'bad' ) ); + } + + public function testIncrease() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ + [ 'count' => 2, 'seconds' => 10, ], + [ 'count' => 4, 'seconds' => 15, 'allIPs' => true ], + ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 2, 'wait' => 10 ], $result ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '2.3.4.5' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '3.4.5.6' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '3.4.5.6' ); + $this->assertSame( [ 'throttleIndex' => 1, 'count' => 4, 'wait' => 15 ], $result ); + } + + public function testZeroCount() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ [ 'count' => 0, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, count=0 is ignored' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, count=0 is ignored' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, count=0 is ignored' ); + } + + public function testNamespacing() { + $cache = new \HashBagOStuff(); + $throttler1 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], + [ 'cache' => $cache, 'type' => 'foo' ] ); + $throttler2 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], + [ 'cache' => $cache, 'type' => 'foo' ] ); + $throttler3 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], + [ 'cache' => $cache, 'type' => 'bar' ] ); + $throttler1->setLogger( new NullLogger() ); + $throttler2->setLogger( new NullLogger() ); + $throttler3->setLogger( new NullLogger() ); + + $throttled = [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ]; + + $result = $throttler1->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler1->increase( 'SomeUser', '1.2.3.4' ); + $this->assertEquals( $throttled, $result, 'should throttle' ); + + $result = $throttler2->increase( 'SomeUser', '1.2.3.4' ); + $this->assertEquals( $throttled, $result, 'should throttle, same namespace' ); + + $result = $throttler3->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, different namespace' ); + } + + public function testExpiration() { + $cache = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'add' ] )->getMock(); + $throttler = new Throttler( [ [ 'count' => 3, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $cache->expects( $this->once() )->method( 'add' )->with( $this->anything(), 1, 10 ); + $throttler->increase( 'SomeUser' ); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testException() { + $throttler = new Throttler( [ [ 'count' => 3, 'seconds' => 10 ] ] ); + $throttler->setLogger( new NullLogger() ); + $throttler->increase(); + } + + public function testLog() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + + $logger = $this->getMockBuilder( AbstractLogger::class ) + ->setMethods( [ 'log' ] ) + ->getMockForAbstractClass(); + $logger->expects( $this->never() )->method( 'log' ); + $throttler->setLogger( $logger ); + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $logger = $this->getMockBuilder( AbstractLogger::class ) + ->setMethods( [ 'log' ] ) + ->getMockForAbstractClass(); + $logger->expects( $this->once() )->method( 'log' )->with( $this->anything(), $this->anything(), [ + 'throttle' => 'custom', + 'index' => 0, + 'ip' => '1.2.3.4', + 'username' => 'SomeUser', + 'count' => 1, + 'expiry' => 10, + 'method' => 'foo', + ] ); + $throttler->setLogger( $logger ); + $result = $throttler->increase( 'SomeUser', '1.2.3.4', 'foo' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + } + + public function testClear() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + + $throttler->clear( 'SomeUser', '1.2.3.4' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php new file mode 100644 index 00000000..7dea123c --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php @@ -0,0 +1,176 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\UserDataAuthenticationRequest + */ +class UserDataAuthenticationRequestTest extends AuthenticationRequestTestCase { + + protected function getInstance( array $args = [] ) { + return new UserDataAuthenticationRequest; + } + + protected function setUp() { + parent::setUp(); + $this->setMwGlobals( 'wgHiddenPrefs', [] ); + } + + /** + * @dataProvider providePopulateUser + * @param string $email Email to set + * @param string $realname Realname to set + * @param StatusValue $expect Expected return + */ + public function testPopulateUser( $email, $realname, $expect ) { + $user = new \User(); + $user->setEmail( 'default@example.com' ); + $user->setRealName( 'Fake Name' ); + + $req = new UserDataAuthenticationRequest; + $req->email = $email; + $req->realname = $realname; + $this->assertEquals( $expect, $req->populateUser( $user ) ); + if ( $expect->isOk() ) { + $this->assertSame( $email ?: 'default@example.com', $user->getEmail() ); + $this->assertSame( $realname ?: 'Fake Name', $user->getRealName() ); + } + } + + public static function providePopulateUser() { + $good = \StatusValue::newGood(); + return [ + [ 'email@example.com', 'Real Name', $good ], + [ 'email@example.com', '', $good ], + [ '', 'Real Name', $good ], + [ '', '', $good ], + [ 'invalid-email', 'Real Name', \StatusValue::newFatal( 'invalidemailaddress' ) ], + ]; + } + + /** + * @dataProvider provideLoadFromSubmission + */ + public function testLoadFromSubmission( + array $args, array $data, $expectState /* $hiddenPref, $enableEmail */ + ) { + list( $args, $data, $expectState, $hiddenPref, $enableEmail ) = func_get_args(); + $this->setMwGlobals( 'wgHiddenPrefs', $hiddenPref ); + $this->setMwGlobals( 'wgEnableEmail', $enableEmail ); + parent::testLoadFromSubmission( $args, $data, $expectState ); + } + + public function provideLoadFromSubmission() { + $unhidden = []; + $hidden = [ 'realname' ]; + + return [ + 'Empty request, unhidden, email enabled' => [ + [], + [], + false, + $unhidden, + true + ], + 'email + realname, unhidden, email enabled' => [ + [], + $data = [ 'email' => 'Email', 'realname' => 'Name' ], + $data, + $unhidden, + true + ], + 'email empty, unhidden, email enabled' => [ + [], + $data = [ 'email' => '', 'realname' => 'Name' ], + $data, + $unhidden, + true + ], + 'email omitted, unhidden, email enabled' => [ + [], + [ 'realname' => 'Name' ], + false, + $unhidden, + true + ], + 'realname empty, unhidden, email enabled' => [ + [], + $data = [ 'email' => 'Email', 'realname' => '' ], + $data, + $unhidden, + true + ], + 'realname omitted, unhidden, email enabled' => [ + [], + [ 'email' => 'Email' ], + false, + $unhidden, + true + ], + 'Empty request, hidden, email enabled' => [ + [], + [], + false, + $hidden, + true + ], + 'email + realname, hidden, email enabled' => [ + [], + [ 'email' => 'Email', 'realname' => 'Name' ], + [ 'email' => 'Email' ], + $hidden, + true + ], + 'email empty, hidden, email enabled' => [ + [], + $data = [ 'email' => '', 'realname' => 'Name' ], + [ 'email' => '' ], + $hidden, + true + ], + 'email omitted, hidden, email enabled' => [ + [], + [ 'realname' => 'Name' ], + false, + $hidden, + true + ], + 'realname empty, hidden, email enabled' => [ + [], + $data = [ 'email' => 'Email', 'realname' => '' ], + [ 'email' => 'Email' ], + $hidden, + true + ], + 'realname omitted, hidden, email enabled' => [ + [], + [ 'email' => 'Email' ], + [ 'email' => 'Email' ], + $hidden, + true + ], + 'email + realname, unhidden, email disabled' => [ + [], + [ 'email' => 'Email', 'realname' => 'Name' ], + [ 'realname' => 'Name' ], + $unhidden, + false + ], + 'email omitted, unhidden, email disabled' => [ + [], + [ 'realname' => 'Name' ], + [ 'realname' => 'Name' ], + $unhidden, + false + ], + 'email empty, unhidden, email disabled' => [ + [], + [ 'email' => '', 'realname' => 'Name' ], + [ 'realname' => 'Name' ], + $unhidden, + false + ], + ]; + } +} diff --git a/www/wiki/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php new file mode 100644 index 00000000..63628dd8 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php @@ -0,0 +1,34 @@ +<?php + +namespace MediaWiki\Auth; + +/** + * @group AuthManager + * @covers MediaWiki\Auth\UsernameAuthenticationRequest + */ +class UsernameAuthenticationRequestTest extends AuthenticationRequestTestCase { + + protected function getInstance( array $args = [] ) { + return new UsernameAuthenticationRequest(); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [], + [], + false + ], + 'Username' => [ + [], + $data = [ 'username' => 'User' ], + $data, + ], + 'Username empty' => [ + [], + [ 'username' => '' ], + false + ], + ]; + } +} |