| Current File : /home/jvzmxxx/wiki1/extensions/Wikibase/repo/tests/phpunit/includes/EditEntityTest.php |
<?php
namespace Wikibase\Test;
use FauxRequest;
use MediaWikiTestCase;
use ObjectCache;
use PHPUnit_Framework_MockObject_Matcher_Invocation;
use ReflectionMethod;
use RequestContext;
use Status;
use Title;
use User;
use Wikibase\DataModel\Entity\EntityDocument;
use Wikibase\DataModel\Entity\EntityId;
use Wikibase\DataModel\Entity\Item;
use Wikibase\DataModel\Entity\ItemId;
use Wikibase\DataModel\Term\Fingerprint;
use Wikibase\EditEntity;
use Wikibase\Lib\Store\EntityRevisionLookup;
use Wikibase\Lib\Store\EntityTitleLookup;
use Wikibase\Lib\Tests\MockRepository;
use Wikibase\Repo\Hooks\EditFilterHookRunner;
use Wikibase\Repo\Store\EntityPermissionChecker;
/**
* @covers Wikibase\EditEntity
*
* @group Wikibase
* @group WikibaseRepo
* @group EditEntity
*
* @group Database
* ^--- needed just because we are using Title objects.
*
* @license GPL-2.0+
* @author Daniel Kinzler
*/
class EditEntityTest extends MediaWikiTestCase {
private function getUser( $name ) {
$user = User::newFromName( $name );
if ( $user->getId() === 0 ) {
$user = User::createNew( $user->getName() );
}
return $user;
}
/**
* @return EntityTitleLookup
*/
private function getEntityTitleLookup() {
$titleLookup = $this->getMock( EntityTitleLookup::class );
$titleLookup->expects( $this->any() )
->method( 'getTitleForID' )
->will( $this->returnCallback( function( EntityId $id ) {
return Title::makeTitle(
NS_MAIN,
$id->getEntityType() . '/' . $id->getSerialization()
);
} ) );
$titleLookup->expects( $this->any() )
->method( 'getNamespaceForType' )
->will( $this->returnValue( NS_MAIN ) );
return $titleLookup;
}
/**
* @param array|null $permissions
*
* @return EntityPermissionChecker
*/
private function getEntityPermissionChecker( array $permissions = null ) {
$permissionChecker = $this->getMock( EntityPermissionChecker::class );
$checkAction = function( $user, $action ) use ( $permissions ) {
if ( $permissions === null
|| ( isset( $permissions[$action] ) && $permissions[$action] )
) {
return Status::newGood( true );
} else {
return Status::newFatal( 'badaccess-group0' );
}
};
$permissionChecker->expects( $this->any() )
->method( 'getPermissionStatusForEntity' )
->will( $this->returnCallback( $checkAction ) );
$permissionChecker->expects( $this->any() )
->method( 'getPermissionStatusForEntityType' )
->will( $this->returnCallback( $checkAction ) );
$permissionChecker->expects( $this->any() )
->method( 'getPermissionStatusForEntityId' )
->will( $this->returnCallback( $checkAction ) );
return $permissionChecker;
}
private function getMockEditFitlerHookRunner(
Status $status = null,
PHPUnit_Framework_MockObject_Matcher_Invocation $expects = null
) {
if ( is_null( $status ) ) {
$status = Status::newGood();
}
if ( is_null( $expects ) ) {
$expects = $this->any();
}
$runner = $this->getMockBuilder( EditFilterHookRunner::class )
->setMethods( array( 'run' ) )
->disableOriginalConstructor()
->getMock();
$runner->expects( $expects )
->method( 'run' )
->will( $this->returnValue( $status ) );
return $runner;
}
/**
* @param MockRepository $mockRepository
* @param EntityDocument $entity
* @param EntityTitleLookup $titleLookup
* @param User|null $user
* @param bool $baseRevId
* @param EditFilterHookRunner|null $editFilterHookRunner
*
* @param null|array $permissions map of actions to bool, indicating which actions are allowed.
*
* @return EditEntity
*/
private function makeEditEntity(
MockRepository $mockRepository,
EntityDocument $entity,
EntityTitleLookup $titleLookup,
User $user = null,
$baseRevId = false,
$permissions = null,
$editFilterHookRunner = null
) {
$context = new RequestContext();
$context->setRequest( new FauxRequest() );
if ( $user === null ) {
$user = User::newFromName( 'EditEntityTestUser' );
}
if ( $editFilterHookRunner === null ) {
$editFilterHookRunner = $this->getMockEditFitlerHookRunner();
}
$permissionChecker = $this->getEntityPermissionChecker( $permissions );
return new EditEntity(
$titleLookup,
$mockRepository,
$mockRepository,
$permissionChecker,
$entity,
$user,
$editFilterHookRunner,
$baseRevId,
$context
);
}
/**
* @return MockRepository
*/
private function getMockRepository() {
$repo = new MockRepository();
$user = $this->getUser( 'EditEntityTestUser1' );
$otherUser = $this->getUser( 'EditEntityTestUser2' );
/* @var Item $item */
$item = new Item( new ItemId( 'Q17' ) );
$item->setLabel( 'en', 'foo' );
$repo->putEntity( $item, 10, 0, $user );
$item = new Item( new ItemId( 'Q17' ) );
$item->setLabel( 'en', 'bar' );
$repo->putEntity( $item, 11, 0, $otherUser );
$item = new Item( new ItemId( 'Q17' ) );
$item->setLabel( 'en', 'bar' );
$item->setLabel( 'de', 'bar' );
$repo->putEntity( $item, 12, 0, $user );
$item = new Item( new ItemId( 'Q17' ) );
$item->setLabel( 'en', 'test' );
$item->setLabel( 'de', 'bar' );
$item->setDescription( 'en', 'more testing' );
$repo->putEntity( $item, 13, 0, $user );
return $repo;
}
public function provideHasEditConflict() {
/*
* Test Revisions:
* #0: label: array( 'en' => 'foo' );
* #1: label: array( 'en' => 'bar' ); // by other user
* #2: label: array( 'en' => 'bar', 'de' => 'bar' );
* #3: label: array( 'en' => 'test', 'de' => 'bar' ), description: array( 'en' => 'more testing' );
*/
return array(
array( // #0: case I: no base rev given.
null, // input data
EntityRevisionLookup::LATEST_FROM_MASTER, // base rev
false, // expected conflict
false, // expected fix
),
array( // #1: case II: base rev == current
null, // input data
13, // base rev
false, // expected conflict
false, // expected fix
),
array( // #2: case IIIa: user was last to edit
array( // input data
'label' => array( 'de' => 'yarrr' ),
),
12, // base rev
true, // expected conflict
true, // expected fix
array( // expected data
'label' => array( 'en' => 'test', 'de' => 'yarrr' ),
)
),
array( // #3: case IIIb: user was last to edit, but intoduces a new operand
array( // input data
'label' => array( 'de' => 'yarrr' ),
),
11, // base rev
true, // expected conflict
false, // expected failure, diff operand change
null
),
array( // #4: case IV: patch applied
array( // input data
'label' => array( 'nl' => 'test', 'fr' => 'frrrrtt' ),
),
10, // base rev
true, // expected conflict
true, // expected fix
array( // expected data
'label' => array( 'de' => 'bar', 'en' => 'test',
'nl' => 'test', 'fr' => 'frrrrtt' ),
)
),
array( // #5: case V: patch failed, expect a conflict
array( // input data
'label' => array( 'nl' => 'test', 'de' => 'bar' ),
),
10, // base rev
true, // expected conflict
false, // expected fix
null // expected data
),
array( // #6: case VI: patch is empty, keep current (not base)
array( // input data
'label' => array( 'en' => 'bar', 'de' => 'bar' ),
),
12, // base rev
true, // expected conflict
true, // expected fix
array( // expected data
'label' => array( 'en' => 'test', 'de' => 'bar' ),
'description' => array( 'en' => 'more testing' )
)
),
);
}
/**
* @dataProvider provideHasEditConflict
*/
public function testHasEditConflict(
array $inputData = null,
$baseRevisionId,
$expectedConflict,
$expectedFix,
array $expectedData = null
) {
$repo = $this->getMockRepository();
$entityId = new ItemId( 'Q17' );
$revision = $repo->getEntityRevision( $entityId, $baseRevisionId );
/** @var Item $item */
$item = $revision->getEntity();
// NOTE: The user name must be the one used in getMockRepository().
$user = $this->getUser( 'EditEntityTestUser1' );
// change entity ----------------------------------
if ( $inputData === null ) {
$item = new Item( $item->getId() );
} else {
if ( !empty( $inputData['label'] ) ) {
foreach ( $inputData['label'] as $k => $v ) {
$item->setLabel( $k, $v );
}
}
if ( !empty( $inputData['description'] ) ) {
foreach ( $inputData['description'] as $k => $v ) {
$item->setDescription( $k, $v );
}
}
if ( !empty( $inputData['aliases'] ) ) {
foreach ( $inputData['aliases'] as $k => $v ) {
$item->setAliases( $k, $v );
}
}
}
// save entity ----------------------------------
$titleLookup = $this->getEntityTitleLookup();
$editEntity = $this->makeEditEntity( $repo, $item, $titleLookup, $user, $baseRevisionId );
$conflict = $editEntity->hasEditConflict();
$this->assertEquals( $expectedConflict, $conflict, 'hasEditConflict()' );
if ( $conflict ) {
$fixed = $editEntity->fixEditConflict();
$this->assertEquals( $expectedFix, $fixed, 'fixEditConflict()' );
}
if ( $expectedData !== null ) {
/** @var Item $newEntity */
$newEntity = $editEntity->getNewEntity();
$data = $this->fingerprintToPartialArray( $newEntity->getFingerprint() );
foreach ( $expectedData as $key => $expectedValue ) {
$actualValue = $data[$key];
$this->assertArrayEquals( $expectedValue, $actualValue, false, true );
}
}
}
private function fingerprintToPartialArray( Fingerprint $fingerprint ) {
return array(
'label' => $fingerprint->getLabels()->toTextArray(),
'description' => $fingerprint->getDescriptions()->toTextArray(),
);
}
public function provideAttemptSaveWithLateConflict() {
return array(
array( true, true ),
array( false, false ),
);
}
/**
* @dataProvider provideAttemptSaveWithLateConflict
*/
public function testAttemptSaveWithLateConflict( $baseRevId, $expectedConflict ) {
$repo = $this->getMockRepository();
$user = $this->getUser( 'EditEntityTestUser' );
// create item
$entity = new Item( new ItemId( 'Q42' ) );
$entity->setLabel( 'en', 'Test' );
$repo->putEntity( $entity, 0, 0, $user );
// begin editing the entity
$entity = new Item( new ItemId( 'Q42' ) );
$entity->setLabel( 'en', 'Trust' );
$titleLookup = $this->getEntityTitleLookup();
$editEntity = $this->makeEditEntity( $repo, $entity, $titleLookup, $user, $baseRevId );
$editEntity->getLatestRevision(); // make sure EditEntity has page and revision
$this->assertEquals( $baseRevId !== false, $editEntity->doesCheckForEditConflicts(), 'doesCheckForEditConflicts()' );
// create independent Entity instance for the same entity, and modify and save it
$entity2 = new Item( new ItemId( 'Q42' ) );
$entity2->setLabel( 'en', 'Toast' );
$user2 = $this->getUser( 'EditEntityTestUser2' );
$repo->putEntity( $entity2, 0, 0, $user2 );
// now try to save the original edit. The conflict should still be detected
$token = $user->getEditToken();
$status = $editEntity->attemptSave( "Testing", EDIT_UPDATE, $token );
$id = $entity->getId()->getSerialization();
if ( $status->isOK() ) {
$statusMessage = "Status ($id): OK";
} else {
$statusMessage = "Status ($id): " . $status->getWikiText();
}
$this->assertNotEquals( $expectedConflict, $status->isOK(),
"Saving should have failed late if and only if a base rev was provided.\n$statusMessage" );
$this->assertEquals( $expectedConflict, $editEntity->hasError(),
"Saving should have failed late if and only if a base rev was provided.\n$statusMessage" );
$this->assertEquals( $expectedConflict, $status->hasMessage( 'edit-conflict' ),
"Saving should have failed late if and only if a base rev was provided.\n$statusMessage" );
$this->assertEquals( $expectedConflict, $editEntity->showErrorPage(),
"If and only if there was an error, an error page should be shown.\n$statusMessage" );
}
public function testErrorPage_DoesNotDoubleEscapeHtmlCharacters() {
$repo = $this->getMockRepository();
$permissions = array();
$context = new RequestContext();
// Cannot reuse makeEditEntity because we need the access the context
$editEntity = new EditEntity(
$this->getEntityTitleLookup(),
$repo,
$repo,
$this->getEntityPermissionChecker( $permissions ),
new Item(),
$this->getUser( 'EditEntityTestUser' ),
$this->getMockEditFitlerHookRunner(),
false,
$context
);
$editEntity->checkEditPermissions();
$editEntity->showErrorPage();
$html = $context->getOutput()->getHTML();
$this->assertContains( '<li>', $html, 'Unescaped HTML' );
$this->assertNotContains( '&lt;', $html, 'No double escaping' );
}
public function dataCheckEditPermissions() {
return array(
array( #0: edit allowed for new item
array( 'read' => true, 'edit' => true, 'createpage' => true ),
false,
true,
),
array( #3: edit not allowed for existing item
array( 'read' => true, 'edit' => false ),
true,
false,
),
);
}
private function prepareItemForPermissionCheck( User $user, MockRepository $mockRepository, $create ) {
$item = new Item();
if ( $create ) {
$item->setLabel( 'de', 'Test' );
$mockRepository->putEntity( $item, 0, 0, $user );
}
return $item;
}
/**
* @dataProvider dataCheckEditPermissions
*/
public function testCheckEditPermissions( $permissions, $create, $expectedOK ) {
$repo = $this->getMockRepository();
$user = $this->getUser( 'EditEntityTestUser' );
$item = $this->prepareItemForPermissionCheck( $user, $repo, $create );
$titleLookup = $this->getEntityTitleLookup();
$edit = $this->makeEditEntity( $repo, $item, $titleLookup, $user, false, $permissions );
$edit->checkEditPermissions();
$this->assertEquals( $expectedOK, $edit->getStatus()->isOK() );
$this->assertNotEquals( $expectedOK, $edit->hasError( EditEntity::PERMISSION_ERROR ) );
}
/**
* @dataProvider dataCheckEditPermissions
*/
public function testAttemptSavePermissions( $permissions, $create, $expectedOK ) {
$repo = $this->getMockRepository();
$titleLookup = $this->getEntityTitleLookup();
$user = $this->getUser( 'EditEntityTestUser' );
$item = $this->prepareItemForPermissionCheck( $user, $repo, $create );
$token = $user->getEditToken();
$edit = $this->makeEditEntity( $repo, $item, $titleLookup, $user, false, $permissions );
$edit->attemptSave( "testing", ( $item->getId() === null ? EDIT_NEW : EDIT_UPDATE ), $token );
$this->assertEquals( $expectedOK, $edit->getStatus()->isOK(), var_export( $edit->getStatus()->getErrorsArray(), true ) );
$this->assertNotEquals( $expectedOK, $edit->hasError( EditEntity::PERMISSION_ERROR ) );
}
/**
* Forces the group membership of the given user
*
* @param User $user
* @param array $groups
*/
private function setUserGroups( User $user, array $groups ) {
if ( $user->getId() === 0 ) {
$user = User::createNew( $user->getName() );
}
$remove = array_diff( $user->getGroups(), $groups );
$add = array_diff( $groups, $user->getGroups() );
foreach ( $remove as $group ) {
$user->removeGroup( $group );
}
foreach ( $add as $group ) {
$user->addGroup( $group );
}
}
public function dataAttemptSaveRateLimit() {
return array(
array( // #0: no limits
array(), // limits: none
array(), // groups: none
array( // edits:
array( 'item' => 'foo', 'label' => 'foo', 'ok' => true ),
array( 'item' => 'bar', 'label' => 'bar', 'ok' => true ),
array( 'item' => 'foo', 'label' => 'Foo', 'ok' => true ),
array( 'item' => 'bar', 'label' => 'Bar', 'ok' => true ),
)
),
array( // #1: limits bypassed with noratelimit permission
array( // limits:
'edit' => array(
'user' => array( 1, 60 ), // one edit per minute
)
),
array( // groups:
'sysop' // sysop has the noratelimit permission set in the test case
),
array( // edits:
array( 'item' => 'foo', 'label' => 'foo', 'ok' => true ),
array( 'item' => 'bar', 'label' => 'bar', 'ok' => true ),
array( 'item' => 'foo', 'label' => 'Foo', 'ok' => true ),
array( 'item' => 'bar', 'label' => 'Bar', 'ok' => true ),
)
),
array( // #2: per-group limit overrides with less restrictive limit
array( // limits:
'edit' => array(
'user' => array( 1, 60 ), // one edit per minute
'kittens' => array( 10, 60 ), // one edit per minute
)
),
array( // groups:
'kittens'
),
array( // edits:
array( 'item' => 'foo', 'label' => 'foo', 'ok' => true ),
array( 'item' => 'bar', 'label' => 'bar', 'ok' => true ),
array( 'item' => 'foo', 'label' => 'Foo', 'ok' => true ),
array( 'item' => 'bar', 'label' => 'Bar', 'ok' => true ),
)
),
array( // #3: edit limit applies
array( // limits:
'edit' => array(
'user' => array( 1, 60 ), // one edit per minute
),
),
array(), // groups: none
array( // edits:
array( 'item' => 'foo', 'label' => 'foo', 'ok' => true ),
array( 'item' => 'foo', 'label' => 'Foo', 'ok' => false ),
)
),
array( // #4: edit limit also applies to creations
array( // limits:
'edit' => array(
'user' => array( 1, 60 ), // one edit per minute
),
'create' => array(
'user' => array( 10, 60 ), // ten creations per minute
),
),
array(), // groups: none
array( // edits:
array( 'item' => 'foo', 'label' => 'foo', 'ok' => true ),
array( 'item' => 'bar', 'label' => 'bar', 'ok' => false ),
array( 'item' => 'foo', 'label' => 'Foo', 'ok' => false ),
)
),
array( // #5: creation limit applies in addition to edit limit
array( // limits:
'edit' => array(
'user' => array( 10, 60 ), // ten edits per minute
),
'create' => array(
'user' => array( 1, 60 ), // ...but only one creation
),
),
array(), // groups: none
array( // edits:
array( 'item' => 'foo', 'label' => 'foo', 'ok' => true ),
array( 'item' => 'foo', 'label' => 'Foo', 'ok' => true ),
array( 'item' => 'bar', 'label' => 'bar', 'ok' => false ),
)
)
);
}
/**
* @dataProvider dataAttemptSaveRateLimit
*/
public function testAttemptSaveRateLimit( array $limits, array $groups, array $edits ) {
$repo = $this->getMockRepository();
$this->setMwGlobals(
'wgRateLimits',
$limits
);
$this->setMwGlobals(
'wgGroupPermissions',
array(
'*' => array( 'edit' => true ),
'sysop' => array( 'noratelimit' => true )
)
);
// make sure we have a working cache
$this->setMwGlobals(
'wgMainCacheType',
CACHE_ANYTHING
);
// make sure we have a fresh cache
ObjectCache::clear();
$user = $this->getUser( 'UserForTestAttemptSaveRateLimit' );
$this->setUserGroups( $user, $groups );
$items = array();
$titleLookup = $this->getEntityTitleLookup();
foreach ( $edits as $e ) {
$name = $e[ 'item' ];
$label = $e[ 'label' ];
$expectedOK = $e[ 'ok' ];
if ( isset( $items[$name] ) ) {
// re-use item
$item = $items[$name];
} else {
// create item
$item = new Item();
$items[$name] = $item;
}
$item->setLabel( 'en', $label );
$edit = $this->makeEditEntity( $repo, $item, $titleLookup, $user );
$edit->attemptSave( "testing", ( $item->getId() === null ? EDIT_NEW : EDIT_UPDATE ), false );
$this->assertEquals( $expectedOK, $edit->getStatus()->isOK(), var_export( $edit->getStatus()->getErrorsArray(), true ) );
$this->assertNotEquals( $expectedOK, $edit->hasError( EditEntity::RATE_LIMIT ) );
}
// make sure nobody else has to work with our cache
ObjectCache::clear();
}
public function provideIsTokenOk() {
return array(
array( //0
true, // use a newly generated valid token
true, // should work
),
array( //1
"xyz", // use an invalid token
false, // should fail
),
array( //2
"", // use an empty token
false, // should fail
),
array( //3
null, // use no token
false, // should fail
),
);
}
/**
* @dataProvider provideIsTokenOk
*/
public function testIsTokenOk( $token, $shouldWork ) {
$repo = $this->getMockRepository();
$user = $this->getUser( 'EditEntityTestUser' );
$item = new Item();
$titleLookup = $this->getEntityTitleLookup();
$edit = $this->makeEditEntity( $repo, $item, $titleLookup, $user );
// check valid token --------------------
if ( $token === true ) {
$token = $user->getEditToken();
}
$this->assertEquals( $shouldWork, $edit->isTokenOK( $token ) );
$this->assertEquals( $shouldWork, $edit->getStatus()->isOK() );
$this->assertNotEquals( $shouldWork, $edit->hasError( EditEntity::TOKEN_ERROR ) );
$this->assertNotEquals( $shouldWork, $edit->showErrorPage() );
}
public function provideAttemptSaveWatch() {
// $watchdefault, $watchcreations, $new, $watched, $watch, $expected
return array(
array( true, true, true, false, null, true ), // watch new
array( true, true, true, false, false, false ), // override watch new
array( true, true, false, false, null, true ), // watch edit
array( true, true, false, false, false, false ), // override watch edit
array( false, false, false, false, null, false ), // don't watch edit
array( false, false, false, false, true, true ), // override don't watch edit
array( false, false, false, true, null, true ), // watch watched
array( false, false, false, true, false, false ), // override don't watch edit
);
}
/**
* @dataProvider provideAttemptSaveWatch
*/
public function testAttemptSaveWatch( $watchdefault, $watchcreations, $new, $watched, $watch, $expected ) {
$repo = $this->getMockRepository();
$user = $this->getUser( 'EditEntityTestUser2' );
if ( $user->getId() === 0 ) {
$user->addToDatabase();
}
$user->setOption( 'watchdefault', $watchdefault );
$user->setOption( 'watchcreations', $watchcreations );
$item = new Item();
$item->setLabel( "en", "Test" );
if ( !$new ) {
$repo->putEntity( $item );
$repo->updateWatchlist( $user, $item->getId(), $watched );
}
$titleLookup = $this->getEntityTitleLookup();
$edit = $this->makeEditEntity( $repo, $item, $titleLookup, $user );
$status = $edit->attemptSave( "testing", $new ? EDIT_NEW : EDIT_UPDATE, false, $watch );
$this->assertTrue( $status->isOK(), "edit failed: " . $status->getWikiText() ); // sanity
$this->assertEquals( $expected, $repo->isWatching( $user, $item->getId() ), "watched" );
}
public function testIsNew() {
$repo = $this->getMockRepository();
$titleLookup = $this->getEntityTitleLookup();
$item = new Item();
$isNew = new ReflectionMethod( EditEntity::class, 'isNew' );
$isNew->setAccessible( true );
$edit = $this->makeEditEntity( $repo, $item, $titleLookup );
$this->assertTrue( $isNew->invoke( $edit ), 'New entity: No id' );
$repo->assignFreshId( $item );
$edit = $this->makeEditEntity( $repo, $item, $titleLookup );
$this->assertTrue( $isNew->invoke( $edit ), "New entity: Has an id, but doesn't exist, yet" );
$repo->saveEntity( $item, 'testIsNew', $this->getUser( 'EditEntityTestUser1' ) );
$edit = $this->makeEditEntity( $repo, $item, $titleLookup );
$this->assertFalse( $isNew->invoke( $edit ), "Entity exists" );
}
public function provideHookRunnerReturnStatus() {
return array(
array( Status::newGood() ),
array( Status::newFatal( 'OMG' ) ),
);
}
/**
* @dataProvider provideHookRunnerReturnStatus
*/
public function testEditFilterHookRunnerInteraction( Status $hookReturnStatus ) {
$edit = $this->makeEditEntity(
$this->getMockRepository(),
new Item(),
$this->getEntityTitleLookup(),
null,
false,
null,
$this->getMockEditFitlerHookRunner( $hookReturnStatus, $this->once() )
);
$user = $this->getUser( 'EditEntityTestUser' );
$saveStatus = $edit->attemptSave(
'some Summary',
EDIT_MINOR,
$user->getEditToken()
);
$this->assertEquals( $hookReturnStatus->isGood(), $saveStatus->isGood() );
}
}