wiki.techinc.nl/tests/phpunit/integration/includes/Rest/Handler/CreationHandlerTest.php
Kosta Harlan ce10edfbc4 CreationHandlerTest: Don't access MW services in dataProvider
Bug: T297292
Change-Id: I2e7d4fa7e9fd0861ecb4eaf0687c9dc1cbe144f7
2021-12-08 15:25:20 +01:00

560 lines
16 KiB
PHP

<?php
namespace MediaWiki\Tests\Rest\Handler;
use ApiUsageException;
use HashConfig;
use MediaWiki\Content\IContentHandlerFactory;
use MediaWiki\Rest\Handler\CreationHandler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Storage\MutableRevisionRecord;
use MediaWiki\Storage\SlotRecord;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWikiIntegrationTestCase;
use MockTitleTrait;
use PHPUnit\Framework\MockObject\MockObject;
use Status;
use Wikimedia\Message\MessageValue;
use Wikimedia\Message\ParamType;
use Wikimedia\Message\ScalarParam;
use WikitextContent;
/**
* @covers \MediaWiki\Rest\Handler\CreationHandler
*/
class CreationHandlerTest extends MediaWikiIntegrationTestCase {
use ActionModuleBasedHandlerTestTrait;
use DummyServicesTrait;
use MockTitleTrait;
private function newHandler( $resultData, $throwException = null, $csrfSafe = false ) {
$config = new HashConfig( [
'RightsUrl' => 'https://creativecommons.org/licenses/by-sa/4.0/',
'RightsText' => 'CC-BY-SA 4.0'
] );
/** @var IContentHandlerFactory|MockObject $contentHandlerFactory */
$contentHandlerFactory =
$this->createNoOpMock( IContentHandlerFactory::class, [ 'isDefinedModel' ] );
$contentHandlerFactory
->method( 'isDefinedModel' )
->willReturnMap( [
[ CONTENT_MODEL_WIKITEXT, true ],
[ CONTENT_MODEL_TEXT, true ],
] );
// DummyServicesTrait::getDummyMediaWikiTitleCodec
$titleCodec = $this->getDummyMediaWikiTitleCodec();
/** @var RevisionLookup|MockObject $revisionLookup */
$revisionLookup = $this->createNoOpMock( RevisionLookup::class, [ 'getRevisionById' ] );
$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" ) );
return $rev;
} );
$handler = new CreationHandler(
$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 function provideExecute() {
// NOTE: Prefix hard coded in a fake for Router::getRouteUrl() in HandlerTestTrait
$baseUrl = 'https://wiki.example.com/rest/v1/page/';
yield "create with token" => [
[ // Request data received by CreationHandler
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'token' => 'TOKEN',
'title' => 'Foo',
'source' => 'Lorem Ipsum',
'comment' => 'Testing'
] ),
],
[ // Fake request expected to be passed into ApiEditPage
'title' => 'Foo',
'text' => 'Lorem Ipsum',
'summary' => 'Testing',
'createonly' => '1',
],
[ // 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 CreationHandler
'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'
],
$baseUrl . 'Foo',
false
];
yield "create with model" => [
[ // Request data received by CreationHandler
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'title' => 'Talk:Foo',
'source' => 'Lorem Ipsum',
'comment' => 'Testing',
'content_model' => CONTENT_MODEL_TEXT,
] ),
],
[ // Fake request expected to be passed into ApiEditPage
'title' => 'Talk:Foo',
'text' => 'Lorem Ipsum',
'summary' => 'Testing',
'contentmodel' => CONTENT_MODEL_TEXT,
'createonly' => '1',
'token' => '+\\',
],
[ // Mock response returned by ApiEditPage
"edit" => [
"new" => true,
"result" => "Success",
"pageid" => 94542,
"title" => "Talk:Foo",
"contentmodel" => CONTENT_MODEL_TEXT,
"oldrevid" => 0,
"newrevid" => 371707,
"newtimestamp" => "2018-12-18T16:59:42Z",
]
],
[ // Response expected to be generated by CreationHandler
'id' => 94542,
'title' => 'Talk:Foo',
'key' => 'Talk:Foo',
'content_model' => CONTENT_MODEL_TEXT,
'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'
],
$baseUrl . 'Talk:Foo',
true
];
yield "create without token" => [
[ // Request data received by CreationHandler
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'title' => 'foo/bar',
'source' => 'Lorem Ipsum',
'comment' => 'Testing',
'content_model' => CONTENT_MODEL_WIKITEXT,
] ),
],
[ // Fake request expected to be passed into ApiEditPage
'title' => 'foo/bar',
'text' => 'Lorem Ipsum',
'summary' => 'Testing',
'contentmodel' => 'wikitext',
'createonly' => '1',
'token' => '+\\', // use known-good token for current user (anon)
],
[ // Mock response returned by ApiEditPage
"edit" => [
"new" => true,
"result" => "Success",
"pageid" => 94542,
"title" => "Foo/bar",
"contentmodel" => "wikitext",
"oldrevid" => 0,
"newrevid" => 371707,
"newtimestamp" => "2018-12-18T16:59:42Z",
]
],
[ // Response expected to be generated by CreationHandler
'id' => 94542,
'title' => 'Foo/bar',
'key' => 'Foo/bar',
'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'
],
$baseUrl . 'Foo%2Fbar',
true
];
yield "create with space" => [
[ // Request data received by CreationHandler
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'title' => 'foo (ba+r)',
'source' => 'Lorem Ipsum',
'comment' => 'Testing'
] ),
],
[ // Fake request expected to be passed into ApiEditPage
'title' => 'foo (ba+r)',
'text' => 'Lorem Ipsum',
'summary' => 'Testing',
'createonly' => '1',
'token' => '+\\', // use known-good token for current user (anon)
],
[ // Mock response returned by ApiEditPage
"edit" => [
"new" => true,
"result" => "Success",
"pageid" => 94542,
"title" => "Foo (ba+r)",
"contentmodel" => "wikitext",
"oldrevid" => 0,
"newrevid" => 371707,
"newtimestamp" => "2018-12-18T16:59:42Z",
]
],
[ // Response expected to be generated by CreationHandler
'id' => 94542,
'title' => 'Foo (ba+r)',
'key' => 'Foo_(ba+r)',
'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'
],
$baseUrl . 'Foo_(ba%2Br)',
true
];
}
/**
* @dataProvider provideExecute
*/
public function testExecute(
$requestData,
$expectedActionParams,
$actionResult,
$expectedResponse,
$expectedRedirect,
$csrfSafe
) {
$request = new RequestData( $requestData );
$handler = $this->newHandler( $actionResult, null, $csrfSafe );
$response = $this->executeHandler( $handler, $request );
$this->assertSame( 201, $response->getStatusCode() );
$this->assertSame(
$expectedRedirect,
$response->getHeaderLine( 'Location' )
);
$this->assertSame( 'application/json', $response->getHeaderLine( 'Content-Type' ) );
$responseData = json_decode( $response->getBody(), true );
$this->assertIsArray( $responseData, 'Body must be a JSON array' );
// Check parameters passed to ApiEditPage by CreationHandler based on $requestData
foreach ( $expectedActionParams as $key => $value ) {
$this->assertSame(
$value,
$handler->getApiMain()->getVal( $key ),
"ApiEditPage param: $key"
);
}
// Check response that CreationHandler created after receiving $actionResult from ApiEditPage
foreach ( $expectedResponse as $key => $value ) {
$this->assertArrayHasKey( $key, $responseData );
$this->assertSame(
$value,
$responseData[ $key ],
"CreationHandler response field: $key"
);
}
}
public function provideBodyValidation() {
yield "missing source field" => [
[ // Request data received by CreationHandler
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'token' => 'TOKEN',
'title' => 'Foo',
'comment' => 'Testing',
'content_model' => CONTENT_MODEL_WIKITEXT,
] ),
],
new MessageValue( 'rest-missing-body-field', [ 'source' ] ),
];
yield "missing comment field" => [
[ // Request data received by CreationHandler
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'token' => 'TOKEN',
'title' => 'Foo',
'source' => 'Lorem Ipsum',
'content_model' => CONTENT_MODEL_WIKITEXT,
] ),
],
new MessageValue( 'rest-missing-body-field', [ 'comment' ] ),
];
yield "missing title field" => [
[ // Request data received by CreationHandler
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'token' => 'TOKEN',
'comment' => 'Testing',
'source' => 'Lorem Ipsum',
'content_model' => CONTENT_MODEL_WIKITEXT,
] ),
],
new MessageValue( 'rest-missing-body-field', [ 'title' ] ),
];
}
/**
* @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 function provideHeaderValidation() {
yield "bad content type" => [
[ // Request data received by CreationHandler
'method' => 'POST',
'headers' => [
'Content-Type' => 'text/plain',
],
'bodyContents' => json_encode( [
'title' => 'Foo',
'source' => 'Lorem Ipsum',
'comment' => 'Testing',
'content_model' => CONTENT_MODEL_WIKITEXT,
] ),
],
415
];
}
public function testBodyValidation_extraneousToken() {
$requestData = [
'method' => 'POST',
'pathParams' => [ 'title' => 'Foo' ],
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'title' => 'Foo',
'token' => 'TOKEN',
'comment' => 'Testing',
'source' => 'Lorem Ipsum',
'content_model' => 'wikitext'
] ),
];
$request = new RequestData( $requestData );
$handler = $this->newHandler( [], null, true );
$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
$this->assertSame( 400, $exception->getCode(), 'HTTP status' );
$this->assertInstanceOf( LocalizedHttpException::class, $exception );
$expectedMessage = new MessageValue( 'rest-extraneous-csrf-token' );
$this->assertEquals( $expectedMessage, $exception->getMessageValue() );
}
/**
* @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: Status::newFatal invokes MediaWikiServices, which is not allowed in a dataProvider.
*/
public 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( 'apierror-articleexists' ), 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', [ 'plaintext' => '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() {
$provideErrorMapping = $this->provideErrorMapping();
foreach ( $provideErrorMapping as $expected ) {
$apiUsageException = $expected[0];
$expectedHttpException = $expected[1];
$requestData = [ // Request data received by CreationHandler
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
],
'bodyContents' => json_encode( [
'title' => 'Foo',
'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()
);
}
}
}
}