Localisation updates from https://translatewiki.net.
[mediawiki.git] / tests / phpunit / includes / user / BotPasswordTest.php
blobff7117c91f9767c5f04fc94244708e3399c1c827
1 <?php
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;
19 /**
20 * @covers \MediaWiki\User\BotPassword
21 * @group Database
23 class BotPasswordTest extends MediaWikiIntegrationTestCase {
25 private TestUser $testUser;
27 /** @var string */
28 private $testUserName;
30 protected function setUp(): void {
31 parent::setUp();
33 $this->overrideConfigValues( [
34 MainConfigNames::EnableBotPasswords => true,
35 MainConfigNames::CentralIdLookupProvider => 'BotPasswordTest OkMock',
36 MainConfigNames::GrantPermissions => [
37 'test' => [ 'read' => true ],
39 MainConfigNames::UserrightsInterwikiDelimiter => '@',
40 ] );
42 $this->testUser = $this->getMutableTestUser();
43 $this->testUserName = $this->testUser->getUser()->getName();
45 $mock1 = $this->getMockForAbstractClass( CentralIdLookup::class );
46 $mock1->method( 'isAttached' )
47 ->willReturn( true );
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 ) {
61 return $mock1;
62 } ],
63 'BotPasswordTest FailMock' => [ 'factory' => static function () use ( $mock2 ) {
64 return $mock2;
65 } ],
66 ] );
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' )
80 ->row( [
81 'bp_user' => 42,
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"]',
87 ] )
88 ->row( [
89 'bp_user' => 43,
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"]',
95 ] )
96 ->caller( __METHOD__ )
97 ->execute();
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( [
124 'user' => $user,
125 'appId' => 'DoesNotExist'
126 ] );
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' ],
139 ] );
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( [
148 'centralId' => 45,
149 'appId' => 'DoesNotExist'
150 ] );
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( [
158 'user' => $user,
159 'appId' => 'BotPassword'
160 ] );
161 $this->assertInstanceOf( BotPassword::class, $bp );
162 $this->assertFalse( $bp->isSaved() );
164 $this->assertNull( BotPassword::newUnsaved( [
165 'user' => $user,
166 'appId' => '',
167 ] ) );
168 $this->assertNull( BotPassword::newUnsaved( [
169 'user' => $user,
170 'appId' => str_repeat( 'X', BotPassword::APPID_MAXLENGTH + 1 ),
171 ] ) );
172 $this->assertNull( BotPassword::newUnsaved( [
173 'user' => $this->testUserName,
174 'appId' => 'Ok',
175 ] ) );
176 $this->assertNull( BotPassword::newUnsaved( [
177 'username' => 'UTInvalid',
178 'appId' => 'Ok',
179 ] ) );
180 $this->assertNull( BotPassword::newUnsaved( [
181 'appId' => 'Ok',
182 ] ) );
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' ) );
193 $bp->centralId = 44;
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 );
239 } else {
240 $this->assertSame( $expectedResult, $result );
244 public static function provideCanonicalizeLoginData() {
245 return [
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,
271 ] );
272 $reset = TestUtils::setSessionManagerSingleton( $manager );
273 $this->assertNull(
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' ],
290 ] );
291 $manager = new SessionManager( [
292 'config' => new MultiConfig( [ $config, $mainConfig ] ),
293 'logger' => new Psr\Log\NullLogger,
294 'store' => new EmptyBagOStuff,
295 ] );
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 );
302 // No base user
303 $status = BotPassword::login( 'UTDummy@BotPassword', 'foobaz', new FauxRequest );
304 $this->assertStatusError( 'nosuchuser', $status );
306 // No bot password
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' ] )
313 ->getMock();
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 );
319 // Wrong password
320 $status = BotPassword::login(
321 "{$this->testUserName}@BotPassword", $this->testUser->getPassword(), new FauxRequest );
322 $this->assertStatusError( 'wrongpassword', $status );
324 // Success!
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( [
351 'centralId' => 42,
352 'appId' => 'TestSave',
353 'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
354 'grants' => [ 'test' ],
355 ] );
356 $this->assertFalse( $bp->isSaved() );
357 $this->assertNull(
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 );
377 } else {
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 );
395 } else {
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() {
417 return [
418 [ null ],
419 [ 'foobar' ],
424 * Tests for error handling when bp_restrictions and bp_grants are too long
426 public function testSaveValidation() {
427 $lotsOfIPs = [
428 'IPAddresses' => array_fill(
430 5000,
431 "127.0.0.0/8"
435 $bp = BotPassword::newUnsaved( [
436 'centralId' => 42,
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 ),
441 'grants' => [
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 )
448 ] );
450 $status = $bp->save( 'insert' );
452 $this->assertStatusError( 'botpasswords-toolong-restrictions', $status );
453 $this->assertStatusError( 'botpasswords-toolong-grants', $status );