]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blobdiff - tests/phpunit/includes/auth/AuthManagerTest.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / tests / phpunit / includes / auth / AuthManagerTest.php
diff --git a/tests/phpunit/includes/auth/AuthManagerTest.php b/tests/phpunit/includes/auth/AuthManagerTest.php
new file mode 100644 (file)
index 0000000..c18af8b
--- /dev/null
@@ -0,0 +1,3627 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use MediaWiki\Session\SessionInfo;
+use MediaWiki\Session\UserInfo;
+use Psr\Log\LogLevel;
+use StatusValue;
+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 \\Psr\\Log\\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' )
+                       ->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", [], "Mock$class"
+                       );
+                       $mocks[$key . '2']->expects( $this->any() )->method( 'getUniqueId' )
+                               ->will( $this->returnValue( $key . '2' ) );
+                       $mocks[$key . '3'] = $this->getMockForAbstractClass(
+                               "MediaWiki\\Auth\\$class", [], "Mock$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', $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(),
+                       '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',
+                       '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", [], "Mock$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', $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", []
+               );
+               $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", [], "Mock$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' )
+                       ->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' )
+                       ->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' )
+                       ->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->getMockForAbstractClass(
+                               "MediaWiki\\Auth\\$class", [], "Mock$class"
+                       );
+                       $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->getMockForAbstractClass(
+                               "MediaWiki\\Auth\\$class", [], "Mock$class"
+                       );
+                       $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->getMockForAbstractClass(
+                       PrimaryAuthenticationProvider::class, [], "MockPrimaryAuthenticationProvider"
+               );
+               $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", [], "Mock$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, [], "MockPrimaryAuthenticationProvider" );
+               $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', $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( '' ),
+                               ]
+                       ],
+               ];
+       }
+}