3 use MediaWiki\Config\HashConfig
;
4 use MediaWiki\Config\MultiConfig
;
5 use MediaWiki\MainConfigNames
;
6 use MediaWiki\Password\InvalidPassword
;
7 use MediaWiki\Password\Password
;
8 use MediaWiki\Request\FauxRequest
;
9 use MediaWiki\Session\SessionManager
;
10 use MediaWiki\Status\Status
;
11 use MediaWiki\Tests\Session\TestUtils
;
12 use MediaWiki\User\BotPassword
;
13 use MediaWiki\User\CentralId\CentralIdLookup
;
14 use Wikimedia\ObjectCache\EmptyBagOStuff
;
15 use Wikimedia\Rdbms\IDBAccessObject
;
16 use Wikimedia\ScopedCallback
;
17 use Wikimedia\TestingAccessWrapper
;
20 * @covers \MediaWiki\User\BotPassword
23 class BotPasswordTest
extends MediaWikiIntegrationTestCase
{
25 private TestUser
$testUser;
28 private $testUserName;
30 protected function setUp(): void
{
33 $this->overrideConfigValues( [
34 MainConfigNames
::EnableBotPasswords
=> true,
35 MainConfigNames
::CentralIdLookupProvider
=> 'BotPasswordTest OkMock',
36 MainConfigNames
::GrantPermissions
=> [
37 'test' => [ 'read' => true ],
39 MainConfigNames
::UserrightsInterwikiDelimiter
=> '@',
42 $this->testUser
= $this->getMutableTestUser();
43 $this->testUserName
= $this->testUser
->getUser()->getName();
45 $mock1 = $this->getMockForAbstractClass( CentralIdLookup
::class );
46 $mock1->method( 'isAttached' )
48 $mock1->method( 'lookupUserNames' )
49 ->willReturn( [ $this->testUserName
=> 42, 'UTDummy' => 43, 'UTInvalid' => 0 ] );
50 $mock1->expects( $this->never() )->method( 'lookupCentralIds' );
52 $mock2 = $this->getMockForAbstractClass( CentralIdLookup
::class );
53 $mock2->method( 'isAttached' )
54 ->willReturn( false );
55 $mock2->method( 'lookupUserNames' )
56 ->willReturnArgument( 0 );
57 $mock2->expects( $this->never() )->method( 'lookupCentralIds' );
59 $this->mergeMwGlobalArrayValue( 'wgCentralIdLookupProviders', [
60 'BotPasswordTest OkMock' => [ 'factory' => static function () use ( $mock1 ) {
63 'BotPasswordTest FailMock' => [ 'factory' => static function () use ( $mock2 ) {
69 public function addDBData() {
70 $passwordFactory = $this->getServiceContainer()->getPasswordFactory();
71 $passwordHash = $passwordFactory->newFromPlaintext( 'foobaz' );
73 $dbw = $this->getDb();
74 $dbw->newDeleteQueryBuilder()
75 ->deleteFrom( 'bot_passwords' )
76 ->where( [ 'bp_user' => [ 42, 43 ], 'bp_app_id' => 'BotPassword' ] )
77 ->caller( __METHOD__
)->execute();
78 $dbw->newInsertQueryBuilder()
79 ->insertInto( 'bot_passwords' )
82 'bp_app_id' => 'BotPassword',
83 'bp_password' => $passwordHash->toString(),
84 'bp_token' => 'token!',
85 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
86 'bp_grants' => '["test"]',
90 'bp_app_id' => 'BotPassword',
91 'bp_password' => $passwordHash->toString(),
92 'bp_token' => 'token!',
93 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
94 'bp_grants' => '["test"]',
96 ->caller( __METHOD__
)
100 public function testBasics() {
101 $user = $this->testUser
->getUser();
102 $bp = BotPassword
::newFromUser( $user, 'BotPassword' );
103 $this->assertInstanceOf( BotPassword
::class, $bp );
104 $this->assertTrue( $bp->isSaved() );
105 $this->assertSame( 42, $bp->getUserCentralId() );
106 $this->assertSame( 'BotPassword', $bp->getAppId() );
107 $this->assertSame( 'token!', trim( $bp->getToken(), " \0" ) );
108 $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
109 $this->assertSame( [ 'test' ], $bp->getGrants() );
111 $this->assertNull( BotPassword
::newFromUser( $user, 'DoesNotExist' ) );
113 $this->overrideConfigValue( MainConfigNames
::CentralIdLookupProvider
, 'BotPasswordTest FailMock' );
114 $this->assertNull( BotPassword
::newFromUser( $user, 'BotPassword' ) );
116 $this->assertSame( '@', BotPassword
::getSeparator() );
117 $this->overrideConfigValue( MainConfigNames
::UserrightsInterwikiDelimiter
, '#' );
118 $this->assertSame( '#', BotPassword
::getSeparator() );
121 public function testUnsaved() {
122 $user = $this->testUser
->getUser();
123 $bp = BotPassword
::newUnsaved( [
125 'appId' => 'DoesNotExist'
127 $this->assertInstanceOf( BotPassword
::class, $bp );
128 $this->assertFalse( $bp->isSaved() );
129 $this->assertSame( 42, $bp->getUserCentralId() );
130 $this->assertSame( 'DoesNotExist', $bp->getAppId() );
131 $this->assertEquals( MWRestrictions
::newDefault(), $bp->getRestrictions() );
132 $this->assertSame( [], $bp->getGrants() );
134 $bp = BotPassword
::newUnsaved( [
135 'username' => 'UTDummy',
136 'appId' => 'DoesNotExist2',
137 'restrictions' => MWRestrictions
::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
138 'grants' => [ 'test' ],
140 $this->assertInstanceOf( BotPassword
::class, $bp );
141 $this->assertFalse( $bp->isSaved() );
142 $this->assertSame( 43, $bp->getUserCentralId() );
143 $this->assertSame( 'DoesNotExist2', $bp->getAppId() );
144 $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
145 $this->assertSame( [ 'test' ], $bp->getGrants() );
147 $bp = BotPassword
::newUnsaved( [
149 'appId' => 'DoesNotExist'
151 $this->assertInstanceOf( BotPassword
::class, $bp );
152 $this->assertFalse( $bp->isSaved() );
153 $this->assertSame( 45, $bp->getUserCentralId() );
154 $this->assertSame( 'DoesNotExist', $bp->getAppId() );
156 $user = $this->testUser
->getUser();
157 $bp = BotPassword
::newUnsaved( [
159 'appId' => 'BotPassword'
161 $this->assertInstanceOf( BotPassword
::class, $bp );
162 $this->assertFalse( $bp->isSaved() );
164 $this->assertNull( BotPassword
::newUnsaved( [
168 $this->assertNull( BotPassword
::newUnsaved( [
170 'appId' => str_repeat( 'X', BotPassword
::APPID_MAXLENGTH +
1 ),
172 $this->assertNull( BotPassword
::newUnsaved( [
173 'user' => $this->testUserName
,
176 $this->assertNull( BotPassword
::newUnsaved( [
177 'username' => 'UTInvalid',
180 $this->assertNull( BotPassword
::newUnsaved( [
185 public function testGetPassword() {
186 /** @var BotPassword $bp */
187 $bp = TestingAccessWrapper
::newFromObject( BotPassword
::newFromCentralId( 42, 'BotPassword' ) );
189 $password = $bp->getPassword();
190 $this->assertInstanceOf( Password
::class, $password );
191 $this->assertTrue( $password->verify( 'foobaz' ) );
194 $password = $bp->getPassword();
195 $this->assertInstanceOf( InvalidPassword
::class, $password );
197 $bp = TestingAccessWrapper
::newFromObject( BotPassword
::newFromCentralId( 42, 'BotPassword' ) );
198 $dbw = $this->getDb();
199 $dbw->newUpdateQueryBuilder()
200 ->update( 'bot_passwords' )
201 ->set( [ 'bp_password' => 'garbage' ] )
202 ->where( [ 'bp_user' => 42, 'bp_app_id' => 'BotPassword' ] )
203 ->caller( __METHOD__
)->execute();
204 $password = $bp->getPassword();
205 $this->assertInstanceOf( InvalidPassword
::class, $password );
208 public function testInvalidateAllPasswordsForUser() {
209 $bp1 = TestingAccessWrapper
::newFromObject( BotPassword
::newFromCentralId( 42, 'BotPassword' ) );
210 $bp2 = TestingAccessWrapper
::newFromObject( BotPassword
::newFromCentralId( 43, 'BotPassword' ) );
212 $this->assertNotInstanceOf( InvalidPassword
::class, $bp1->getPassword() );
213 $this->assertNotInstanceOf( InvalidPassword
::class, $bp2->getPassword() );
214 BotPassword
::invalidateAllPasswordsForUser( $this->testUserName
);
215 $this->assertInstanceOf( InvalidPassword
::class, $bp1->getPassword() );
216 $this->assertNotInstanceOf( InvalidPassword
::class, $bp2->getPassword() );
218 $bp = TestingAccessWrapper
::newFromObject( BotPassword
::newFromCentralId( 42, 'BotPassword' ) );
219 $this->assertInstanceOf( InvalidPassword
::class, $bp->getPassword() );
222 public function testRemoveAllPasswordsForUser() {
223 $this->assertNotNull( BotPassword
::newFromCentralId( 42, 'BotPassword' ) );
224 $this->assertNotNull( BotPassword
::newFromCentralId( 43, 'BotPassword' ) );
226 BotPassword
::removeAllPasswordsForUser( $this->testUserName
);
228 $this->assertNull( BotPassword
::newFromCentralId( 42, 'BotPassword' ) );
229 $this->assertNotNull( BotPassword
::newFromCentralId( 43, 'BotPassword' ) );
233 * @dataProvider provideCanonicalizeLoginData
235 public function testCanonicalizeLoginData( $username, $password, $expectedResult ) {
236 $result = BotPassword
::canonicalizeLoginData( $username, $password );
237 if ( is_array( $expectedResult ) ) {
238 $this->assertArrayEquals( $expectedResult, $result, true, true );
240 $this->assertSame( $expectedResult, $result );
244 public static function provideCanonicalizeLoginData() {
246 [ 'user', 'pass', false ],
247 [ 'user', 'abc@def', false ],
248 [ 'legacy@user', 'pass', false ],
249 [ 'user@bot', '12345678901234567890123456789012',
250 [ 'user@bot', '12345678901234567890123456789012' ] ],
251 [ 'user', 'bot@12345678901234567890123456789012',
252 [ 'user@bot', '12345678901234567890123456789012' ] ],
253 [ 'user', 'bot@12345678901234567890123456789012345',
254 [ 'user@bot', '12345678901234567890123456789012345' ] ],
255 [ 'user', 'bot@x@12345678901234567890123456789012',
256 [ 'user@bot@x', '12345678901234567890123456789012' ] ],
260 public function testLogin() {
261 // Test failure when bot passwords aren't enabled
262 $this->overrideConfigValue( MainConfigNames
::EnableBotPasswords
, false );
263 $status = BotPassword
::login( "{$this->testUserName}@BotPassword", 'foobaz', new FauxRequest
);
264 $this->assertEquals( Status
::newFatal( 'botpasswords-disabled' ), $status );
265 $this->overrideConfigValue( MainConfigNames
::EnableBotPasswords
, true );
267 // Test failure when BotPasswordSessionProvider isn't configured
268 $manager = new SessionManager( [
269 'logger' => new Psr\Log\NullLogger
,
270 'store' => new EmptyBagOStuff
,
272 $reset = TestUtils
::setSessionManagerSingleton( $manager );
274 $manager->getProvider( MediaWiki\Session\BotPasswordSessionProvider
::class )
276 $status = BotPassword
::login( "{$this->testUserName}@BotPassword", 'foobaz', new FauxRequest
);
277 $this->assertEquals( Status
::newFatal( 'botpasswords-no-provider' ), $status );
278 ScopedCallback
::consume( $reset );
280 // Now configure BotPasswordSessionProvider for further tests...
281 $mainConfig = $this->getServiceContainer()->getMainConfig();
282 $config = new HashConfig( [
283 MainConfigNames
::SessionProviders
=> $mainConfig->get( MainConfigNames
::SessionProviders
) +
[
284 MediaWiki\Session\BotPasswordSessionProvider
::class => [
285 'class' => MediaWiki\Session\BotPasswordSessionProvider
::class,
286 'args' => [ [ 'priority' => 40 ] ],
287 'services' => [ 'GrantsInfo' ],
291 $manager = new SessionManager( [
292 'config' => new MultiConfig( [ $config, $mainConfig ] ),
293 'logger' => new Psr\Log\NullLogger
,
294 'store' => new EmptyBagOStuff
,
296 $reset = TestUtils
::setSessionManagerSingleton( $manager );
298 // No "@"-thing in the username
299 $status = BotPassword
::login( $this->testUserName
, 'foobaz', new FauxRequest
);
300 $this->assertStatusError( 'botpasswords-invalid-name', $status );
303 $status = BotPassword
::login( 'UTDummy@BotPassword', 'foobaz', new FauxRequest
);
304 $this->assertStatusError( 'nosuchuser', $status );
307 $status = BotPassword
::login( "{$this->testUserName}@DoesNotExist", 'foobaz', new FauxRequest
);
308 $this->assertStatusError( 'botpasswords-not-exist', $status );
310 // Failed restriction
311 $request = $this->getMockBuilder( FauxRequest
::class )
312 ->onlyMethods( [ 'getIP' ] )
314 $request->method( 'getIP' )
315 ->willReturn( '10.0.0.1' );
316 $status = BotPassword
::login( "{$this->testUserName}@BotPassword", 'foobaz', $request );
317 $this->assertStatusError( 'botpasswords-restriction-failed', $status );
320 $status = BotPassword
::login(
321 "{$this->testUserName}@BotPassword", $this->testUser
->getPassword(), new FauxRequest
);
322 $this->assertStatusError( 'wrongpassword', $status );
325 $request = new FauxRequest
;
326 $this->assertNotInstanceOf(
327 MediaWiki\Session\BotPasswordSessionProvider
::class,
328 $request->getSession()->getProvider()
330 $status = BotPassword
::login( "{$this->testUserName}@BotPassword", 'foobaz', $request );
331 $this->assertInstanceOf( Status
::class, $status );
332 $this->assertStatusGood( $status );
333 $session = $status->getValue();
334 $this->assertInstanceOf( MediaWiki\Session\Session
::class, $session );
335 $this->assertInstanceOf(
336 MediaWiki\Session\BotPasswordSessionProvider
::class, $session->getProvider()
338 $this->assertSame( $session->getId(), $request->getSession()->getId() );
340 ScopedCallback
::consume( $reset );
344 * @dataProvider provideSave
345 * @param string|null $password
347 public function testSave( $password ) {
348 $passwordFactory = $this->getServiceContainer()->getPasswordFactory();
350 $bp = BotPassword
::newUnsaved( [
352 'appId' => 'TestSave',
353 'restrictions' => MWRestrictions
::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
354 'grants' => [ 'test' ],
356 $this->assertFalse( $bp->isSaved() );
358 BotPassword
::newFromCentralId( 42, 'TestSave', IDBAccessObject
::READ_LATEST
)
361 $passwordHash = $password ?
$passwordFactory->newFromPlaintext( $password ) : null;
362 $this->assertStatusNotOk( $bp->save( 'update', $passwordHash ) );
363 $this->assertStatusGood( $bp->save( 'insert', $passwordHash ) );
365 $bp2 = BotPassword
::newFromCentralId( 42, 'TestSave', IDBAccessObject
::READ_LATEST
);
366 $this->assertInstanceOf( BotPassword
::class, $bp2 );
367 $this->assertEquals( $bp->getUserCentralId(), $bp2->getUserCentralId() );
368 $this->assertEquals( $bp->getAppId(), $bp2->getAppId() );
369 $this->assertEquals( $bp->getToken(), $bp2->getToken() );
370 $this->assertEquals( $bp->getRestrictions(), $bp2->getRestrictions() );
371 $this->assertEquals( $bp->getGrants(), $bp2->getGrants() );
373 /** @var Password $pw */
374 $pw = TestingAccessWrapper
::newFromObject( $bp )->getPassword();
375 if ( $password === null ) {
376 $this->assertInstanceOf( InvalidPassword
::class, $pw );
378 $this->assertTrue( $pw->verify( $password ) );
381 $token = $bp->getToken();
382 $this->assertEquals( 42, $bp->getUserCentralId() );
383 $this->assertEquals( 'TestSave', $bp->getAppId() );
384 $this->assertStatusNotOk( $bp->save( 'insert' ) );
385 $this->assertStatusGood( $bp->save( 'update' ) );
386 $this->assertNotEquals( $token, $bp->getToken() );
388 $bp2 = BotPassword
::newFromCentralId( 42, 'TestSave', IDBAccessObject
::READ_LATEST
);
389 $this->assertInstanceOf( BotPassword
::class, $bp2 );
390 $this->assertEquals( $bp->getToken(), $bp2->getToken() );
391 /** @var Password $pw */
392 $pw = TestingAccessWrapper
::newFromObject( $bp )->getPassword();
393 if ( $password === null ) {
394 $this->assertInstanceOf( InvalidPassword
::class, $pw );
396 $this->assertTrue( $pw->verify( $password ) );
399 $passwordHash = $passwordFactory->newFromPlaintext( 'XXX' );
400 $token = $bp->getToken();
401 $this->assertStatusGood( $bp->save( 'update', $passwordHash ) );
402 $this->assertNotEquals( $token, $bp->getToken() );
404 /** @var Password $pw */
405 $pw = TestingAccessWrapper
::newFromObject( $bp )->getPassword();
406 $this->assertTrue( $pw->verify( 'XXX' ) );
408 $this->assertTrue( $bp->delete() );
409 $this->assertFalse( $bp->isSaved() );
410 $this->assertNull( BotPassword
::newFromCentralId( 42, 'TestSave', IDBAccessObject
::READ_LATEST
) );
412 $this->expectException( UnexpectedValueException
::class );
413 $bp->save( 'foobar' )->isGood();
416 public static function provideSave() {
424 * Tests for error handling when bp_restrictions and bp_grants are too long
426 public function testSaveValidation() {
428 'IPAddresses' => array_fill(
435 $bp = BotPassword
::newUnsaved( [
437 'appId' => 'TestSave',
438 // When this becomes JSON, it'll be 70,017 characters, which is
439 // greater than BotPassword::GRANTS_MAXLENGTH, so it will cause an error.
440 'restrictions' => MWRestrictions
::newFromArray( $lotsOfIPs ),
442 // Maximum length of the JSON is BotPassword::RESTRICTIONS_MAXLENGTH characters.
443 // So one long grant name should be good. Turning it into JSON will add
444 // a couple of extra characters, taking it over BotPassword::RESTRICTIONS_MAXLENGTH
445 // characters long, so it will cause an error.
446 str_repeat( '*', BotPassword
::RESTRICTIONS_MAXLENGTH
)
450 $status = $bp->save( 'insert' );
452 $this->assertStatusError( 'botpasswords-toolong-restrictions', $status );
453 $this->assertStatusError( 'botpasswords-toolong-grants', $status );