wiki.techinc.nl/tests/phpunit/includes/user/BotPasswordTest.php
Amir Sarabadani c04f1d64d6 Remove IDBAccessObject from being implemented in many classes
This is inconsistent with the access pattern of other constants in
MediaWiki. it's also confusing (e.g. it's unclear to a newcomer why
UserFactory is implementing IDBAccessObject) and it's prone to clashes
(e.g. BagOStuff class has a clashing constant).

It has been already announced: https://w.wiki/9DAX

Bug: T354194
Change-Id: Ic2357634b8385d65b55db2b557191419b06c40e0
2024-02-19 10:50:02 +01:00

457 lines
16 KiB
PHP

<?php
use MediaWiki\Config\HashConfig;
use MediaWiki\Config\MultiConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Session\SessionManager;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Session\TestUtils;
use MediaWiki\User\BotPassword;
use MediaWiki\User\CentralId\CentralIdLookup;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;
/**
* @covers \MediaWiki\User\BotPassword
* @group Database
*/
class BotPasswordTest extends MediaWikiIntegrationTestCase {
/** @var TestUser */
private $testUser;
/** @var string */
private $testUserName;
protected function setUp(): void {
parent::setUp();
$this->overrideConfigValues( [
MainConfigNames::EnableBotPasswords => true,
MainConfigNames::CentralIdLookupProvider => 'BotPasswordTest OkMock',
MainConfigNames::GrantPermissions => [
'test' => [ 'read' => true ],
],
MainConfigNames::UserrightsInterwikiDelimiter => '@',
] );
$this->testUser = $this->getMutableTestUser();
$this->testUserName = $this->testUser->getUser()->getName();
$mock1 = $this->getMockForAbstractClass( CentralIdLookup::class );
$mock1->method( 'isAttached' )
->willReturn( true );
$mock1->method( 'lookupUserNames' )
->willReturn( [ $this->testUserName => 42, 'UTDummy' => 43, 'UTInvalid' => 0 ] );
$mock1->expects( $this->never() )->method( 'lookupCentralIds' );
$mock2 = $this->getMockForAbstractClass( CentralIdLookup::class );
$mock2->method( 'isAttached' )
->willReturn( false );
$mock2->method( 'lookupUserNames' )
->willReturnArgument( 0 );
$mock2->expects( $this->never() )->method( 'lookupCentralIds' );
$this->mergeMwGlobalArrayValue( 'wgCentralIdLookupProviders', [
'BotPasswordTest OkMock' => [ 'factory' => static function () use ( $mock1 ) {
return $mock1;
} ],
'BotPasswordTest FailMock' => [ 'factory' => static function () use ( $mock2 ) {
return $mock2;
} ],
] );
}
public function addDBData() {
$passwordFactory = $this->getServiceContainer()->getPasswordFactory();
$passwordHash = $passwordFactory->newFromPlaintext( 'foobaz' );
$dbw = $this->getDb();
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'bot_passwords' )
->where( [ 'bp_user' => [ 42, 43 ], 'bp_app_id' => 'BotPassword' ] )
->caller( __METHOD__ )->execute();
$dbw->insert(
'bot_passwords',
[
[
'bp_user' => 42,
'bp_app_id' => 'BotPassword',
'bp_password' => $passwordHash->toString(),
'bp_token' => 'token!',
'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
'bp_grants' => '["test"]',
],
[
'bp_user' => 43,
'bp_app_id' => 'BotPassword',
'bp_password' => $passwordHash->toString(),
'bp_token' => 'token!',
'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
'bp_grants' => '["test"]',
],
],
__METHOD__
);
}
public function testBasics() {
$user = $this->testUser->getUser();
$bp = BotPassword::newFromUser( $user, 'BotPassword' );
$this->assertInstanceOf( BotPassword::class, $bp );
$this->assertTrue( $bp->isSaved() );
$this->assertSame( 42, $bp->getUserCentralId() );
$this->assertSame( 'BotPassword', $bp->getAppId() );
$this->assertSame( 'token!', trim( $bp->getToken(), " \0" ) );
$this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
$this->assertSame( [ 'test' ], $bp->getGrants() );
$this->assertNull( BotPassword::newFromUser( $user, 'DoesNotExist' ) );
$this->overrideConfigValue( MainConfigNames::CentralIdLookupProvider, 'BotPasswordTest FailMock' );
$this->assertNull( BotPassword::newFromUser( $user, 'BotPassword' ) );
$this->assertSame( '@', BotPassword::getSeparator() );
$this->overrideConfigValue( MainConfigNames::UserrightsInterwikiDelimiter, '#' );
$this->assertSame( '#', BotPassword::getSeparator() );
}
public function testUnsaved() {
$user = $this->testUser->getUser();
$bp = BotPassword::newUnsaved( [
'user' => $user,
'appId' => 'DoesNotExist'
] );
$this->assertInstanceOf( BotPassword::class, $bp );
$this->assertFalse( $bp->isSaved() );
$this->assertSame( 42, $bp->getUserCentralId() );
$this->assertSame( 'DoesNotExist', $bp->getAppId() );
$this->assertEquals( MWRestrictions::newDefault(), $bp->getRestrictions() );
$this->assertSame( [], $bp->getGrants() );
$bp = BotPassword::newUnsaved( [
'username' => 'UTDummy',
'appId' => 'DoesNotExist2',
'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
'grants' => [ 'test' ],
] );
$this->assertInstanceOf( BotPassword::class, $bp );
$this->assertFalse( $bp->isSaved() );
$this->assertSame( 43, $bp->getUserCentralId() );
$this->assertSame( 'DoesNotExist2', $bp->getAppId() );
$this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
$this->assertSame( [ 'test' ], $bp->getGrants() );
$bp = BotPassword::newUnsaved( [
'centralId' => 45,
'appId' => 'DoesNotExist'
] );
$this->assertInstanceOf( BotPassword::class, $bp );
$this->assertFalse( $bp->isSaved() );
$this->assertSame( 45, $bp->getUserCentralId() );
$this->assertSame( 'DoesNotExist', $bp->getAppId() );
$user = $this->testUser->getUser();
$bp = BotPassword::newUnsaved( [
'user' => $user,
'appId' => 'BotPassword'
] );
$this->assertInstanceOf( BotPassword::class, $bp );
$this->assertFalse( $bp->isSaved() );
$this->assertNull( BotPassword::newUnsaved( [
'user' => $user,
'appId' => '',
] ) );
$this->assertNull( BotPassword::newUnsaved( [
'user' => $user,
'appId' => str_repeat( 'X', BotPassword::APPID_MAXLENGTH + 1 ),
] ) );
$this->assertNull( BotPassword::newUnsaved( [
'user' => $this->testUserName,
'appId' => 'Ok',
] ) );
$this->assertNull( BotPassword::newUnsaved( [
'username' => 'UTInvalid',
'appId' => 'Ok',
] ) );
$this->assertNull( BotPassword::newUnsaved( [
'appId' => 'Ok',
] ) );
}
public function testGetPassword() {
/** @var BotPassword $bp */
$bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
$password = $bp->getPassword();
$this->assertInstanceOf( Password::class, $password );
$this->assertTrue( $password->verify( 'foobaz' ) );
$bp->centralId = 44;
$password = $bp->getPassword();
$this->assertInstanceOf( InvalidPassword::class, $password );
$bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
$dbw = $this->getDb();
$dbw->newUpdateQueryBuilder()
->update( 'bot_passwords' )
->set( [ 'bp_password' => 'garbage' ] )
->where( [ 'bp_user' => 42, 'bp_app_id' => 'BotPassword' ] )
->caller( __METHOD__ )->execute();
$password = $bp->getPassword();
$this->assertInstanceOf( InvalidPassword::class, $password );
}
public function testInvalidateAllPasswordsForUser() {
$bp1 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
$bp2 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
$this->assertNotInstanceOf( InvalidPassword::class, $bp1->getPassword() );
$this->assertNotInstanceOf( InvalidPassword::class, $bp2->getPassword() );
BotPassword::invalidateAllPasswordsForUser( $this->testUserName );
$this->assertInstanceOf( InvalidPassword::class, $bp1->getPassword() );
$this->assertNotInstanceOf( InvalidPassword::class, $bp2->getPassword() );
$bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
$this->assertInstanceOf( InvalidPassword::class, $bp->getPassword() );
}
public function testRemoveAllPasswordsForUser() {
$this->assertNotNull( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
$this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
BotPassword::removeAllPasswordsForUser( $this->testUserName );
$this->assertNull( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
$this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
}
/**
* @dataProvider provideCanonicalizeLoginData
*/
public function testCanonicalizeLoginData( $username, $password, $expectedResult ) {
$result = BotPassword::canonicalizeLoginData( $username, $password );
if ( is_array( $expectedResult ) ) {
$this->assertArrayEquals( $expectedResult, $result, true, true );
} else {
$this->assertSame( $expectedResult, $result );
}
}
public static function provideCanonicalizeLoginData() {
return [
[ 'user', 'pass', false ],
[ 'user', 'abc@def', false ],
[ 'legacy@user', 'pass', false ],
[ 'user@bot', '12345678901234567890123456789012',
[ 'user@bot', '12345678901234567890123456789012' ] ],
[ 'user', 'bot@12345678901234567890123456789012',
[ 'user@bot', '12345678901234567890123456789012' ] ],
[ 'user', 'bot@12345678901234567890123456789012345',
[ 'user@bot', '12345678901234567890123456789012345' ] ],
[ 'user', 'bot@x@12345678901234567890123456789012',
[ 'user@bot@x', '12345678901234567890123456789012' ] ],
];
}
public function testLogin() {
// Test failure when bot passwords aren't enabled
$this->overrideConfigValue( MainConfigNames::EnableBotPasswords, false );
$status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', new FauxRequest );
$this->assertEquals( Status::newFatal( 'botpasswords-disabled' ), $status );
$this->overrideConfigValue( MainConfigNames::EnableBotPasswords, true );
// Test failure when BotPasswordSessionProvider isn't configured
$manager = new SessionManager( [
'logger' => new Psr\Log\NullLogger,
'store' => new EmptyBagOStuff,
] );
$reset = TestUtils::setSessionManagerSingleton( $manager );
$this->assertNull(
$manager->getProvider( MediaWiki\Session\BotPasswordSessionProvider::class )
);
$status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', new FauxRequest );
$this->assertEquals( Status::newFatal( 'botpasswords-no-provider' ), $status );
ScopedCallback::consume( $reset );
// Now configure BotPasswordSessionProvider for further tests...
$mainConfig = $this->getServiceContainer()->getMainConfig();
$config = new HashConfig( [
MainConfigNames::SessionProviders => $mainConfig->get( 'SessionProviders' ) + [
MediaWiki\Session\BotPasswordSessionProvider::class => [
'class' => MediaWiki\Session\BotPasswordSessionProvider::class,
'args' => [ [ 'priority' => 40 ] ],
'services' => [ 'GrantsInfo' ],
]
],
] );
$manager = new SessionManager( [
'config' => new MultiConfig( [ $config, $mainConfig ] ),
'logger' => new Psr\Log\NullLogger,
'store' => new EmptyBagOStuff,
] );
$reset = TestUtils::setSessionManagerSingleton( $manager );
// No "@"-thing in the username
$status = BotPassword::login( $this->testUserName, 'foobaz', new FauxRequest );
$this->assertStatusError( wfMessage( 'botpasswords-invalid-name', '@' ), $status );
// No base user
$status = BotPassword::login( 'UTDummy@BotPassword', 'foobaz', new FauxRequest );
$this->assertStatusError( wfMessage( 'nosuchuser', 'UTDummy' ), $status );
// No bot password
$status = BotPassword::login( "{$this->testUserName}@DoesNotExist", 'foobaz', new FauxRequest );
$this->assertStatusError(
wfMessage( 'botpasswords-not-exist', $this->testUserName, 'DoesNotExist' ),
$status
);
// Failed restriction
$request = $this->getMockBuilder( FauxRequest::class )
->onlyMethods( [ 'getIP' ] )
->getMock();
$request->method( 'getIP' )
->willReturn( '10.0.0.1' );
$status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', $request );
$this->assertStatusError( wfMessage( 'botpasswords-restriction-failed' ), $status );
// Wrong password
$status = BotPassword::login(
"{$this->testUserName}@BotPassword", $this->testUser->getPassword(), new FauxRequest );
$this->assertStatusError( wfMessage( 'wrongpassword' ), $status );
// Success!
$request = new FauxRequest;
$this->assertNotInstanceOf(
MediaWiki\Session\BotPasswordSessionProvider::class,
$request->getSession()->getProvider()
);
$status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', $request );
$this->assertInstanceOf( Status::class, $status );
$this->assertStatusGood( $status );
$session = $status->getValue();
$this->assertInstanceOf( MediaWiki\Session\Session::class, $session );
$this->assertInstanceOf(
MediaWiki\Session\BotPasswordSessionProvider::class, $session->getProvider()
);
$this->assertSame( $session->getId(), $request->getSession()->getId() );
ScopedCallback::consume( $reset );
}
/**
* @dataProvider provideSave
* @param string|null $password
*/
public function testSave( $password ) {
$passwordFactory = $this->getServiceContainer()->getPasswordFactory();
$bp = BotPassword::newUnsaved( [
'centralId' => 42,
'appId' => 'TestSave',
'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
'grants' => [ 'test' ],
] );
$this->assertFalse( $bp->isSaved() );
$this->assertNull(
BotPassword::newFromCentralId( 42, 'TestSave', IDBAccessObject::READ_LATEST )
);
$passwordHash = $password ? $passwordFactory->newFromPlaintext( $password ) : null;
$this->assertStatusNotOk( $bp->save( 'update', $passwordHash ) );
$this->assertStatusGood( $bp->save( 'insert', $passwordHash ) );
$bp2 = BotPassword::newFromCentralId( 42, 'TestSave', IDBAccessObject::READ_LATEST );
$this->assertInstanceOf( BotPassword::class, $bp2 );
$this->assertEquals( $bp->getUserCentralId(), $bp2->getUserCentralId() );
$this->assertEquals( $bp->getAppId(), $bp2->getAppId() );
$this->assertEquals( $bp->getToken(), $bp2->getToken() );
$this->assertEquals( $bp->getRestrictions(), $bp2->getRestrictions() );
$this->assertEquals( $bp->getGrants(), $bp2->getGrants() );
/** @var Password $pw */
$pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
if ( $password === null ) {
$this->assertInstanceOf( InvalidPassword::class, $pw );
} else {
$this->assertTrue( $pw->verify( $password ) );
}
$token = $bp->getToken();
$this->assertEquals( 42, $bp->getUserCentralId() );
$this->assertEquals( 'TestSave', $bp->getAppId() );
$this->assertStatusNotOk( $bp->save( 'insert' ) );
$this->assertStatusGood( $bp->save( 'update' ) );
$this->assertNotEquals( $token, $bp->getToken() );
$bp2 = BotPassword::newFromCentralId( 42, 'TestSave', IDBAccessObject::READ_LATEST );
$this->assertInstanceOf( BotPassword::class, $bp2 );
$this->assertEquals( $bp->getToken(), $bp2->getToken() );
/** @var Password $pw */
$pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
if ( $password === null ) {
$this->assertInstanceOf( InvalidPassword::class, $pw );
} else {
$this->assertTrue( $pw->verify( $password ) );
}
$passwordHash = $passwordFactory->newFromPlaintext( 'XXX' );
$token = $bp->getToken();
$this->assertStatusGood( $bp->save( 'update', $passwordHash ) );
$this->assertNotEquals( $token, $bp->getToken() );
/** @var Password $pw */
$pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
$this->assertTrue( $pw->verify( 'XXX' ) );
$this->assertTrue( $bp->delete() );
$this->assertFalse( $bp->isSaved() );
$this->assertNull( BotPassword::newFromCentralId( 42, 'TestSave', IDBAccessObject::READ_LATEST ) );
$this->expectException( UnexpectedValueException::class );
$bp->save( 'foobar' )->isGood();
}
public static function provideSave() {
return [
[ null ],
[ 'foobar' ],
];
}
/**
* Tests for error handling when bp_restrictions and bp_grants are too long
*/
public function testSaveValidation() {
$lotsOfIPs = [
'IPAddresses' => array_fill(
0,
5000,
"127.0.0.0/8"
)
];
$bp = BotPassword::newUnsaved( [
'centralId' => 42,
'appId' => 'TestSave',
// When this becomes JSON, it'll be 70,017 characters, which is
// greater than BotPassword::GRANTS_MAXLENGTH, so it will cause an error.
'restrictions' => MWRestrictions::newFromArray( $lotsOfIPs ),
'grants' => [
// Maximum length of the JSON is BotPassword::RESTRICTIONS_MAXLENGTH characters.
// So one long grant name should be good. Turning it into JSON will add
// a couple of extra characters, taking it over BotPassword::RESTRICTIONS_MAXLENGTH
// characters long, so it will cause an error.
str_repeat( '*', BotPassword::RESTRICTIONS_MAXLENGTH )
],
] );
$status = $bp->save( 'insert' );
$this->assertStatusError( 'botpasswords-toolong-restrictions', $status );
$this->assertStatusError( 'botpasswords-toolong-grants', $status );
}
}