wiki.techinc.nl/tests/phpunit/integration/includes/Rest/Handler/UpdateHandlerTest.php
Ebrahim Byagowi a717db8e60 Add namespace and deprecation alias to FormatJson
This patch introduces a namespace declaration for the
MediaWiki\Json to FormatJson and establishes a class
alias marked as deprecated since version 1.43.

Bug: T353458
Change-Id: I5e1311e4eb7a878a7db319b725ae262f40671c32
2024-05-16 16:28:01 +03:30

690 lines
20 KiB
PHP

<?php
namespace MediaWiki\Tests\Rest\Handler;
use ApiUsageException;
use MediaWiki\Config\HashConfig;
use MediaWiki\Json\FormatJson;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Parser\MagicWordFactory;
use MediaWiki\Parser\Parsoid\ParsoidParserFactory;
use MediaWiki\Rest\Handler\UpdateHandler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFactory;
use MediaWikiLangTestCase;
use MockTitleTrait;
use ParserFactory;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\Message\DataMessageValue;
use Wikimedia\Message\MessageValue;
use Wikimedia\Message\ParamType;
use Wikimedia\Message\ScalarParam;
use Wikimedia\UUID\GlobalIdGenerator;
use WikitextContent;
use WikitextContentHandler;
/**
* @group Database
* @covers \MediaWiki\Rest\Handler\UpdateHandler
*/
class UpdateHandlerTest extends MediaWikiLangTestCase {
use ActionModuleBasedHandlerTestTrait;
use DummyServicesTrait;
use MockTitleTrait;
private function newHandler( $resultData, $throwException = null, $csrfSafe = false ) {
$config = new HashConfig( [
MainConfigNames::RightsUrl => 'https://creativecommons.org/licenses/by-sa/4.0/',
MainConfigNames::RightsText => 'CC-BY-SA 4.0'
] );
$wikitextContentHandler = new WikitextContentHandler(
CONTENT_MODEL_WIKITEXT,
$this->createMock( TitleFactory::class ),
$this->createMock( ParserFactory::class ),
$this->createMock( GlobalIdGenerator::class ),
$this->createMock( LanguageNameUtils::class ),
$this->createMock( LinkRenderer::class ),
$this->createMock( MagicWordFactory::class ),
$this->createMock( ParsoidParserFactory::class )
);
// Only wikitext is defined, returns specific handler instance
$contentHandlerFactory = $this->getDummyContentHandlerFactory(
[ CONTENT_MODEL_WIKITEXT => $wikitextContentHandler ]
);
$titleCodec = $this->getDummyMediaWikiTitleCodec();
/** @var RevisionLookup|MockObject $revisionLookup */
$revisionLookup = $this->createNoOpMock(
RevisionLookup::class,
[ 'getRevisionById', 'getRevisionByTitle' ]
);
$revisionLookup->method( 'getRevisionById' )
->willReturnCallback( function ( $id ) {
$title = $this->makeMockTitle( __CLASS__ );
$rev = new MutableRevisionRecord( $title );
$rev->setId( $id );
$rev->setContent( SlotRecord::MAIN, new WikitextContent( "Content of revision $id" ) );
$rev->setTimestamp( '2020-01-01T01:02:03Z' );
return $rev;
} );
$revisionLookup->method( 'getRevisionByTitle' )
->willReturnCallback( static function ( $title ) {
$rev = new MutableRevisionRecord( Title::castFromLinkTarget( $title ) );
$rev->setId( 1234 );
$rev->setContent( SlotRecord::MAIN, new WikitextContent( "Current content of $title" ) );
$rev->setTimestamp( '2020-01-01T01:02:03Z' );
return $rev;
} );
$handler = new UpdateHandler(
$config,
$contentHandlerFactory,
$titleCodec,
$titleCodec,
$revisionLookup
);
$apiMain = $this->getApiMain( $csrfSafe );
$dummyModule = $this->getDummyApiModule( $apiMain, 'edit', $resultData, $throwException );
$handler->setApiMain( $apiMain );
$handler->overrideActionModule(
'edit',
'action',
$dummyModule
);
return $handler;
}
public static function provideExecute() {
yield "create with token" => [
[ // Request data received by UpdateHandler
'method' => 'PUT',
'pathParams' => [ 'title' => 'Foo' ],
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'token' => 'TOKEN',
'source' => 'Lorem Ipsum',
'comment' => 'Testing'
] ),
],
[ // Fake request expected to be passed into ApiEditPage
'title' => 'Foo',
'text' => 'Lorem Ipsum',
'summary' => 'Testing',
'createonly' => '1',
'token' => 'TOKEN',
],
[ // Mock response returned by ApiEditPage
"edit" => [
"new" => true,
"result" => "Success",
"pageid" => 94542,
"title" => "Foo",
"contentmodel" => "wikitext",
"oldrevid" => 0,
"newrevid" => 371707,
"newtimestamp" => "2018-12-18T16:59:42Z",
]
],
[ // Response expected to be generated by UpdateHandler
'id' => 94542,
'title' => 'Foo',
'key' => 'Foo',
'content_model' => 'wikitext',
'latest' => [
'id' => 371707,
'timestamp' => "2018-12-18T16:59:42Z"
],
'license' => [
'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
'title' => 'CC-BY-SA 4.0'
],
'source' => 'Content of revision 371707'
],
false
];
yield "create with model" => [
[ // Request data received by UpdateHandler
'method' => 'POST',
'pathParams' => [ 'title' => 'Foo' ],
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'token' => 'TOKEN',
'source' => 'Lorem Ipsum',
'comment' => 'Testing',
'content_model' => CONTENT_MODEL_WIKITEXT,
] ),
],
[ // Fake request expected to be passed into ApiEditPage
'title' => 'Foo',
'text' => 'Lorem Ipsum',
'summary' => 'Testing',
'contentmodel' => 'wikitext',
'createonly' => '1',
'token' => 'TOKEN',
],
[ // Mock response returned by ApiEditPage
"edit" => [
"new" => true,
"result" => "Success",
"pageid" => 94542,
"title" => "Foo",
"contentmodel" => "wikitext",
"oldrevid" => 0,
"newrevid" => 371707,
"newtimestamp" => "2018-12-18T16:59:42Z",
]
],
[ // Response expected to be generated by UpdateHandler
'id' => 94542,
'title' => 'Foo',
'key' => 'Foo',
'content_model' => 'wikitext',
'latest' => [
'id' => 371707,
'timestamp' => "2018-12-18T16:59:42Z"
],
'license' => [
'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
'title' => 'CC-BY-SA 4.0'
],
'source' => 'Content of revision 371707'
],
false
];
yield "update with token" => [
[ // Request data received by UpdateHandler
'method' => 'PUT',
'pathParams' => [ 'title' => 'foo bar' ],
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'token' => 'TOKEN',
'source' => 'Lorem Ipsum',
'comment' => 'Testing',
'latest' => [ 'id' => 789123 ],
] ),
],
[ // Fake request expected to be passed into ApiEditPage
'title' => 'foo bar',
'text' => 'Lorem Ipsum',
'summary' => 'Testing',
'nocreate' => '1',
'baserevid' => '789123',
'token' => 'TOKEN',
],
[ // Mock response returned by ApiEditPage
"edit" => [
"result" => "Success",
"pageid" => 94542,
"title" => "Foo_bar",
"contentmodel" => "wikitext",
"oldrevid" => 371705,
"newrevid" => 371707,
"newtimestamp" => "2018-12-18T16:59:42Z",
]
],
[ // Response expected to be generated by UpdateHandler
'id' => 94542,
'content_model' => 'wikitext',
'title' => 'Foo bar',
'key' => 'Foo_bar',
'latest' => [
'id' => 371707,
'timestamp' => "2018-12-18T16:59:42Z"
],
'license' => [
'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
'title' => 'CC-BY-SA 4.0'
],
'source' => 'Content of revision 371707'
],
false
];
yield "update with model" => [
[ // Request data received by UpdateHandler
'method' => 'POST',
'pathParams' => [ 'title' => 'foo bar' ],
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'source' => 'Lorem Ipsum',
'comment' => 'Testing',
'content_model' => CONTENT_MODEL_WIKITEXT,
'latest' => [ 'id' => 789123 ],
] ),
],
[ // Fake request expected to be passed into ApiEditPage
'title' => 'foo bar',
'text' => 'Lorem Ipsum',
'summary' => 'Testing',
'contentmodel' => 'wikitext',
'nocreate' => '1',
'baserevid' => '789123',
'token' => '+\\',
],
[ // Mock response returned by ApiEditPage
"edit" => [
"result" => "Success",
"pageid" => 94542,
"title" => "Foo_bar",
"contentmodel" => "wikitext",
"oldrevid" => 371705,
"newrevid" => 371707,
"newtimestamp" => "2018-12-18T16:59:42Z",
]
],
[ // Response expected to be generated by UpdateHandler
'id' => 94542,
'content_model' => 'wikitext',
'title' => 'Foo bar',
'key' => 'Foo_bar',
'latest' => [
'id' => 371707,
'timestamp' => "2018-12-18T16:59:42Z"
],
'license' => [
'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
'title' => 'CC-BY-SA 4.0'
],
'source' => 'Content of revision 371707'
],
true
];
yield "update without token" => [
[ // Request data received by UpdateHandler
'method' => 'PUT',
'pathParams' => [ 'title' => 'Foo' ],
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'source' => 'Lorem Ipsum',
'comment' => 'Testing',
'content_model' => CONTENT_MODEL_WIKITEXT,
'latest' => [ 'id' => 789123 ],
] ),
],
[ // Fake request expected to be passed into ApiEditPage
'title' => 'Foo',
'text' => 'Lorem Ipsum',
'summary' => 'Testing',
'contentmodel' => 'wikitext',
'nocreate' => '1',
'baserevid' => '789123',
'token' => '+\\', // use known-good token for current user (anon)
],
[ // Mock response returned by ApiEditPage
"edit" => [
"result" => "Success",
"pageid" => 94542,
"title" => "Foo",
"contentmodel" => "wikitext",
"oldrevid" => 371705,
"newrevid" => 371707,
"newtimestamp" => "2018-12-18T16:59:42Z",
]
],
[ // Response expected to be generated by UpdateHandler
'id' => 94542,
'content_model' => 'wikitext',
'latest' => [
'id' => 371707,
'timestamp' => "2018-12-18T16:59:42Z"
],
'license' => [
'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
'title' => 'CC-BY-SA 4.0'
],
],
true
];
yield "null-edit (unchanged)" => [
[ // Request data received by UpdateHandler
'method' => 'PUT',
'pathParams' => [ 'title' => 'Foo' ],
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'source' => 'Lorem Ipsum',
'comment' => 'Testing',
'content_model' => CONTENT_MODEL_WIKITEXT,
'latest' => [ 'id' => 789123 ],
] ),
],
[ // Fake request expected to be passed into ApiEditPage
'title' => 'Foo',
'text' => 'Lorem Ipsum',
'summary' => 'Testing',
'contentmodel' => 'wikitext',
'nocreate' => '1',
'baserevid' => '789123',
'token' => '+\\', // use known-good token for current user (anon)
],
[ // Mock response returned by ApiEditPage
"edit" => [
"result" => "Success",
"pageid" => 94542,
"title" => "Foo",
"contentmodel" => "wikitext",
"nochange" => "", // null-edit!
]
],
[ // Response expected to be generated by UpdateHandler
'id' => 94542,
'content_model' => 'wikitext',
'latest' => [
'id' => 1234, // ID of current rev, as defined in newHandler()
'timestamp' => '2020-01-01T01:02:03Z' // see fake RevisionStore in newHandler()
],
'license' => [
'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
'title' => 'CC-BY-SA 4.0'
],
],
true
];
}
/**
* @dataProvider provideExecute
*/
public function testExecute(
$requestData,
$expectedActionParams,
$actionResult,
$expectedResponse,
$csrfSafe
) {
$request = new RequestData( $requestData );
$handler = $this->newHandler( $actionResult, null, $csrfSafe );
$responseData = $this->executeHandlerAndGetBodyData(
$handler, $request, [], [], [], [], null, $this->getSession( $csrfSafe )
);
// Check parameters passed to ApiEditPage by UpdateHandler based on $requestData
foreach ( $expectedActionParams as $key => $value ) {
$this->assertSame(
$value,
$handler->getApiMain()->getVal( $key ),
"ApiEditPage param: $key"
);
}
// Check response that UpdateHandler created after receiving $actionResult from ApiEditPage
foreach ( $expectedResponse as $key => $value ) {
$this->assertArrayHasKey( $key, $responseData );
$this->assertSame(
$value,
$responseData[ $key ],
"UpdateHandler response field: $key"
);
}
}
public static function provideBodyValidation() {
yield "missing source field" => [
[ // Request data received by UpdateHandler
'method' => 'PUT',
'pathParams' => [ 'title' => 'Foo' ],
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'token' => 'TOKEN',
'comment' => 'Testing',
'content_model' => CONTENT_MODEL_WIKITEXT,
] ),
],
MessageValue::new( 'rest-body-validation-error', [
DataMessageValue::new( 'paramvalidator-missingparam', [], 'missingparam' )
->plaintextParams( 'source' )
] ),
];
yield "missing comment field" => [
[ // Request data received by UpdateHandler
'method' => 'PUT',
'pathParams' => [ 'title' => 'Foo' ],
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'token' => 'TOKEN',
'source' => 'Lorem Ipsum',
'content_model' => CONTENT_MODEL_WIKITEXT,
] ),
],
MessageValue::new( 'rest-body-validation-error', [
DataMessageValue::new( 'paramvalidator-missingparam', [], 'missingparam' )
->plaintextParams( 'comment' )
] ),
];
}
/**
* @dataProvider provideBodyValidation
*/
public function testBodyValidation( array $requestData, MessageValue $expectedMessage ) {
$request = new RequestData( $requestData );
$handler = $this->newHandler( [] );
$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
$this->assertSame( 400, $exception->getCode(), 'HTTP status' );
$this->assertInstanceOf( LocalizedHttpException::class, $exception );
/** @var LocalizedHttpException $exception */
$this->assertEquals( $expectedMessage, $exception->getMessageValue() );
}
public static function provideHeaderValidation() {
yield "bad content type" => [
[ // Request data received by UpdateHandler
'method' => 'PUT',
'pathParams' => [ 'title' => 'Foo' ],
'headers' => [
'Content-Type' => 'text/plain',
],
'bodyContents' => json_encode( [
'token' => 'TOKEN',
'source' => 'Lorem Ipsum',
'comment' => 'Testing',
'content_model' => CONTENT_MODEL_WIKITEXT,
] ),
],
415
];
}
/**
* @dataProvider provideHeaderValidation
*/
public function testHeaderValidation( array $requestData, $expectedStatus ) {
$request = new RequestData( $requestData );
$handler = $this->newHandler( [] );
$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
$this->assertSame( $expectedStatus, $exception->getCode(), 'HTTP status' );
}
/**
* FIXME: Can't access MW services in a dataProvider.
*/
public static function provideErrorMapping() {
yield "missingtitle" => [
new ApiUsageException( null, Status::newFatal( 'apierror-missingtitle' ) ),
new LocalizedHttpException( new MessageValue( 'apierror-missingtitle' ), 404 ),
];
yield "protectedpage" => [
new ApiUsageException( null, Status::newFatal( 'apierror-protectedpage' ) ),
new LocalizedHttpException( new MessageValue( 'apierror-protectedpage' ), 403 ),
];
yield "articleexists" => [
new ApiUsageException( null, Status::newFatal( 'apierror-articleexists' ) ),
new LocalizedHttpException(
new MessageValue( 'rest-update-cannot-create-page', [ 'Foo' ] ),
409
),
];
yield "editconflict" => [
new ApiUsageException( null, Status::newFatal( 'apierror-editconflict' ) ),
new LocalizedHttpException( new MessageValue( 'apierror-editconflict' ), 409 ),
];
yield "ratelimited" => [
new ApiUsageException( null, Status::newFatal( 'apierror-ratelimited' ) ),
new LocalizedHttpException( new MessageValue( 'apierror-ratelimited' ), 429 ),
];
yield "badtoken" => [
new ApiUsageException(
null,
Status::newFatal( 'apierror-badtoken', Message::plaintextParam( 'BAD' ) )
),
new LocalizedHttpException(
new MessageValue(
'apierror-badtoken',
[ new ScalarParam( ParamType::PLAINTEXT, 'BAD' ) ]
), 403
),
];
// Unmapped errors should be passed through with a status 400.
yield "no-direct-editing" => [
new ApiUsageException( null, Status::newFatal( 'apierror-no-direct-editing' ) ),
new LocalizedHttpException( new MessageValue( 'apierror-no-direct-editing' ), 400 ),
];
yield "badformat" => [
new ApiUsageException( null, Status::newFatal( 'apierror-badformat' ) ),
new LocalizedHttpException( new MessageValue( 'apierror-badformat' ), 400 ),
];
yield "emptypage" => [
new ApiUsageException( null, Status::newFatal( 'apierror-emptypage' ) ),
new LocalizedHttpException( new MessageValue( 'apierror-emptypage' ), 400 ),
];
}
public function testErrorMapping() {
foreach ( $this->provideErrorMapping() as $expected ) {
$apiUsageException = $expected[0];
$expectedHttpException = $expected[1];
$requestData = [ // Request data received by UpdateHandler
'method' => 'POST',
'pathParams' => [ 'title' => 'Foo' ],
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'source' => 'Lorem Ipsum',
'comment' => 'Testing',
'content_model' => CONTENT_MODEL_WIKITEXT,
] ),
];
$request = new RequestData( $requestData );
$handler = $this->newHandler( [], $apiUsageException );
$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
$this->assertSame( $expectedHttpException->getMessage(), $exception->getMessage() );
$this->assertSame( $expectedHttpException->getCode(), $exception->getCode(), 'HTTP status' );
$errorData = $exception->getErrorData();
if ( $expectedHttpException->getErrorData() ) {
foreach ( $expectedHttpException->getErrorData() as $key => $value ) {
$this->assertSame( $value, $errorData[$key], 'Error data key $key' );
}
}
if ( $expectedHttpException instanceof LocalizedHttpException ) {
/** @var LocalizedHttpException $exception */
$this->assertEquals(
$expectedHttpException->getMessageValue(),
$exception->getMessageValue()
);
}
}
}
public function testConflictOutput() {
$requestData = [ // Request data received by UpdateHandler
'method' => 'POST',
'pathParams' => [ 'title' => 'Foo' ],
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'latest' => [
'id' => 17,
],
'source' => 'Lorem Ipsum',
'comment' => 'Testing'
] ),
];
$request = new RequestData( $requestData );
$apiUsageException = new ApiUsageException( null, Status::newFatal( 'apierror-editconflict' ) );
$handler = $this->newHandler( [], $apiUsageException );
$handler->setJsonDiffFunction( [ $this, 'fakeJsonDiff' ] );
$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
$this->assertSame( 409, $exception->getCode(), 'HTTP status' );
$expectedData = [
'local' => [
'from' => 'Content of revision 17',
'to' => 'Lorem Ipsum',
],
'remote' => [
'from' => 'Content of revision 17',
'to' => 'Current content of 0:Foo',
],
'base' => 17,
'current' => 1234
];
$errorData = $exception->getErrorData();
foreach ( $expectedData as $key => $value ) {
$this->assertSame( $value, $errorData[$key], "Error data key $key" );
}
}
public function fakeJsonDiff( $fromText, $toText ) {
return FormatJson::encode( [
'from' => $fromText,
'to' => $toText
] );
}
}