1<?php
2
3namespace MediaWiki\Tests\Rest\Handler;
4
5use ApiUsageException;
6use HashConfig;
7use MediaWiki\Content\IContentHandlerFactory;
8use MediaWiki\Rest\Handler\CreationHandler;
9use MediaWiki\Rest\HttpException;
10use MediaWiki\Rest\LocalizedHttpException;
11use MediaWiki\Rest\RequestData;
12use MediaWiki\Revision\RevisionLookup;
13use MediaWiki\Storage\MutableRevisionRecord;
14use MediaWiki\Storage\SlotRecord;
15use MediaWikiIntegrationTestCase;
16use MediaWikiTitleCodec;
17use MockTitleTrait;
18use PHPUnit\Framework\MockObject\MockObject;
19use Status;
20use Wikimedia\Message\MessageValue;
21use Wikimedia\Message\ParamType;
22use Wikimedia\Message\ScalarParam;
23use WikitextContent;
24
25/**
26 * @covers \MediaWiki\Rest\Handler\CreationHandler
27 */
28class CreationHandlerTest extends MediaWikiIntegrationTestCase {
29
30	use ActionModuleBasedHandlerTestTrait;
31	use MockTitleTrait;
32
33	private function newHandler( $resultData, $throwException = null, $csrfSafe = false ) {
34		$config = new HashConfig( [
35			'RightsUrl' => 'https://creativecommons.org/licenses/by-sa/4.0/',
36			'RightsText' => 'CC-BY-SA 4.0'
37		] );
38
39		/** @var IContentHandlerFactory|MockObject $contentHandlerFactory */
40		$contentHandlerFactory =
41			$this->createNoOpMock( IContentHandlerFactory::class, [ 'isDefinedModel' ] );
42
43		$contentHandlerFactory
44			->method( 'isDefinedModel' )
45			->willReturnMap( [
46				[ CONTENT_MODEL_WIKITEXT, true ],
47				[ CONTENT_MODEL_TEXT, true ],
48			] );
49
50		/** @var MockObject|MediaWikiTitleCodec $titleCodec */
51		$titleCodec = $this->getMockBuilder( MediaWikiTitleCodec::class )
52			->disableOriginalConstructor()
53			->onlyMethods( [ 'formatTitle', 'splitTitleString' ] )
54			->getMock();
55
56		$titleCodec->method( 'formatTitle' )
57			->willReturnCallback( static function ( $namespace, $text ) {
58				return "ns:$namespace:" . ucfirst( $text );
59			} );
60		$titleCodec->method( 'splitTitleString' )
61			->willReturnCallback( static function ( $text ) {
62				return [
63					'interwiki' => '',
64					'fragment' => '',
65					'namespace' => 0,
66					'dbkey' => str_replace( ' ', '_', $text )
67				];
68			} );
69
70		/** @var RevisionLookup|MockObject $revisionLookup */
71		$revisionLookup = $this->createNoOpMock( RevisionLookup::class, [ 'getRevisionById' ] );
72		$revisionLookup->method( 'getRevisionById' )
73			->willReturnCallback( function ( $id ) {
74				$title = $this->makeMockTitle( __CLASS__ );
75				$rev = new MutableRevisionRecord( $title );
76				$rev->setId( $id );
77				$rev->setContent( SlotRecord::MAIN, new WikitextContent( "Content of revision $id" ) );
78				return $rev;
79			} );
80
81		$handler = new CreationHandler(
82			$config,
83			$contentHandlerFactory,
84			$titleCodec,
85			$titleCodec,
86			$revisionLookup
87		);
88
89		$apiMain = $this->getApiMain( $csrfSafe );
90		$dummyModule = $this->getDummyApiModule( $apiMain, 'edit', $resultData, $throwException );
91
92		$handler->setApiMain( $apiMain );
93		$handler->overrideActionModule(
94			'edit',
95			'action',
96			$dummyModule
97		);
98
99		return $handler;
100	}
101
102	public function provideExecute() {
103		// NOTE: Prefix hard coded in a fake for Router::getRouteUrl() in HandlerTestTrait
104		$baseUrl = 'https://wiki.example.com/rest/v1/page/';
105
106		yield "create with token" => [
107			[ // Request data received by CreationHandler
108				'method' => 'POST',
109				'headers' => [
110					'Content-Type' => 'application/json',
111				],
112				'bodyContents' => json_encode( [
113					'token' => 'TOKEN',
114					'title' => 'Foo',
115					'source' => 'Lorem Ipsum',
116					'comment' => 'Testing'
117				] ),
118			],
119			[ // Fake request expected to be passed into ApiEditPage
120				'title' => 'Foo',
121				'text' => 'Lorem Ipsum',
122				'summary' => 'Testing',
123				'createonly' => '1',
124			],
125			[ // Mock response returned by ApiEditPage
126				"edit" => [
127					"new" => true,
128					"result" => "Success",
129					"pageid" => 94542,
130					"title" => "Foo",
131					"contentmodel" => "wikitext",
132					"oldrevid" => 0,
133					"newrevid" => 371707,
134					"newtimestamp" => "2018-12-18T16:59:42Z",
135				]
136			],
137			[ // Response expected to be generated by CreationHandler
138				'id' => 94542,
139				'title' => 'ns:0:Foo',
140				'key' => 'ns:0:Foo',
141				'content_model' => 'wikitext',
142				'latest' => [
143					'id' => 371707,
144					'timestamp' => "2018-12-18T16:59:42Z"
145				],
146				'license' => [
147					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
148					'title' => 'CC-BY-SA 4.0'
149				],
150				'source' => 'Content of revision 371707'
151			],
152			$baseUrl . 'Foo',
153			false
154		];
155
156		yield "create with model" => [
157			[ // Request data received by CreationHandler
158				'method' => 'POST',
159				'headers' => [
160					'Content-Type' => 'application/json',
161				],
162				'bodyContents' => json_encode( [
163					'title' => 'Talk:Foo',
164					'source' => 'Lorem Ipsum',
165					'comment' => 'Testing',
166					'content_model' => CONTENT_MODEL_TEXT,
167				] ),
168			],
169			[ // Fake request expected to be passed into ApiEditPage
170				'title' => 'Talk:Foo',
171				'text' => 'Lorem Ipsum',
172				'summary' => 'Testing',
173				'contentmodel' => CONTENT_MODEL_TEXT,
174				'createonly' => '1',
175				'token' => '+\\',
176			],
177			[ // Mock response returned by ApiEditPage
178				"edit" => [
179					"new" => true,
180					"result" => "Success",
181					"pageid" => 94542,
182					"title" => "Talk:Foo",
183					"contentmodel" => CONTENT_MODEL_TEXT,
184					"oldrevid" => 0,
185					"newrevid" => 371707,
186					"newtimestamp" => "2018-12-18T16:59:42Z",
187				]
188			],
189			[ // Response expected to be generated by CreationHandler
190				'id' => 94542,
191				'title' => 'ns:0:Talk:Foo', // our mock TitleCodec doesn't parse namespaces
192				'key' => 'ns:0:Talk:Foo',
193				'content_model' => CONTENT_MODEL_TEXT,
194				'latest' => [
195					'id' => 371707,
196					'timestamp' => "2018-12-18T16:59:42Z"
197				],
198				'license' => [
199					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
200					'title' => 'CC-BY-SA 4.0'
201				],
202				'source' => 'Content of revision 371707'
203			],
204			$baseUrl . 'Talk:Foo',
205			true
206		];
207
208		yield "create without token" => [
209			[ // Request data received by CreationHandler
210				'method' => 'POST',
211				'headers' => [
212					'Content-Type' => 'application/json',
213				],
214				'bodyContents' => json_encode( [
215					'title' => 'foo/bar',
216					'source' => 'Lorem Ipsum',
217					'comment' => 'Testing',
218					'content_model' => CONTENT_MODEL_WIKITEXT,
219				] ),
220			],
221			[ // Fake request expected to be passed into ApiEditPage
222				'title' => 'foo/bar',
223				'text' => 'Lorem Ipsum',
224				'summary' => 'Testing',
225				'contentmodel' => 'wikitext',
226				'createonly' => '1',
227				'token' => '+\\', // use known-good token for current user (anon)
228			],
229			[ // Mock response returned by ApiEditPage
230				"edit" => [
231					"new" => true,
232					"result" => "Success",
233					"pageid" => 94542,
234					"title" => "Foo/bar",
235					"contentmodel" => "wikitext",
236					"oldrevid" => 0,
237					"newrevid" => 371707,
238					"newtimestamp" => "2018-12-18T16:59:42Z",
239				]
240			],
241			[ // Response expected to be generated by CreationHandler
242				'id' => 94542,
243				'title' => 'ns:0:Foo/bar',
244				'key' => 'ns:0:Foo/bar',
245				'content_model' => 'wikitext',
246				'latest' => [
247					'id' => 371707,
248					'timestamp' => "2018-12-18T16:59:42Z"
249				],
250				'license' => [
251					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
252					'title' => 'CC-BY-SA 4.0'
253				],
254				'source' => 'Content of revision 371707'
255			],
256			$baseUrl . 'Foo%2Fbar',
257			true
258		];
259
260		yield "create with space" => [
261			[ // Request data received by CreationHandler
262				'method' => 'POST',
263				'headers' => [
264					'Content-Type' => 'application/json',
265				],
266				'bodyContents' => json_encode( [
267					'title' => 'foo (ba+r)',
268					'source' => 'Lorem Ipsum',
269					'comment' => 'Testing'
270				] ),
271			],
272			[ // Fake request expected to be passed into ApiEditPage
273				'title' => 'foo (ba+r)',
274				'text' => 'Lorem Ipsum',
275				'summary' => 'Testing',
276				'createonly' => '1',
277				'token' => '+\\', // use known-good token for current user (anon)
278			],
279			[ // Mock response returned by ApiEditPage
280				"edit" => [
281					"new" => true,
282					"result" => "Success",
283					"pageid" => 94542,
284					"title" => "Foo (ba+r)",
285					"contentmodel" => "wikitext",
286					"oldrevid" => 0,
287					"newrevid" => 371707,
288					"newtimestamp" => "2018-12-18T16:59:42Z",
289				]
290			],
291			[ // Response expected to be generated by CreationHandler
292				'id' => 94542,
293				'title' => 'ns:0:Foo (ba+r)',
294				'key' => 'ns:0:Foo_(ba+r)',
295				'content_model' => 'wikitext',
296				'latest' => [
297					'id' => 371707,
298					'timestamp' => "2018-12-18T16:59:42Z"
299				],
300				'license' => [
301					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
302					'title' => 'CC-BY-SA 4.0'
303				],
304				'source' => 'Content of revision 371707'
305			],
306			$baseUrl . 'Foo_(ba%2Br)',
307			true
308		];
309	}
310
311	/**
312	 * @dataProvider provideExecute
313	 */
314	public function testExecute(
315		$requestData,
316		$expectedActionParams,
317		$actionResult,
318		$expectedResponse,
319		$expectedRedirect,
320		$csrfSafe
321	) {
322		$request = new RequestData( $requestData );
323
324		$handler = $this->newHandler( $actionResult, null, $csrfSafe );
325
326		$response = $this->executeHandler( $handler, $request );
327
328		$this->assertSame( 201, $response->getStatusCode() );
329		$this->assertSame(
330			$expectedRedirect,
331			$response->getHeaderLine( 'Location' )
332		);
333		$this->assertSame( 'application/json', $response->getHeaderLine( 'Content-Type' ) );
334
335		$responseData = json_decode( $response->getBody(), true );
336		$this->assertIsArray( $responseData, 'Body must be a JSON array' );
337
338		// Check parameters passed to ApiEditPage by CreationHandler based on $requestData
339		foreach ( $expectedActionParams as $key => $value ) {
340			$this->assertSame(
341				$value,
342				$handler->getApiMain()->getVal( $key ),
343				"ApiEditPage param: $key"
344			);
345		}
346
347		// Check response that CreationHandler created after receiving $actionResult from ApiEditPage
348		foreach ( $expectedResponse as $key => $value ) {
349			$this->assertArrayHasKey( $key, $responseData );
350			$this->assertSame(
351				$value,
352				$responseData[ $key ],
353				"CreationHandler response field: $key"
354			);
355		}
356	}
357
358	public function provideBodyValidation() {
359		yield "missing source field" => [
360			[ // Request data received by CreationHandler
361				'method' => 'POST',
362				'headers' => [
363					'Content-Type' => 'application/json',
364				],
365				'bodyContents' => json_encode( [
366					'token' => 'TOKEN',
367					'title' => 'Foo',
368					'comment' => 'Testing',
369					'content_model' => CONTENT_MODEL_WIKITEXT,
370				] ),
371			],
372			new MessageValue( 'rest-missing-body-field', [ 'source' ] ),
373		];
374		yield "missing comment field" => [
375			[ // Request data received by CreationHandler
376				'method' => 'POST',
377				'headers' => [
378					'Content-Type' => 'application/json',
379				],
380				'bodyContents' => json_encode( [
381					'token' => 'TOKEN',
382					'title' => 'Foo',
383					'source' => 'Lorem Ipsum',
384					'content_model' => CONTENT_MODEL_WIKITEXT,
385				] ),
386			],
387			new MessageValue( 'rest-missing-body-field', [ 'comment' ] ),
388		];
389		yield "missing title field" => [
390			[ // Request data received by CreationHandler
391				'method' => 'POST',
392				'headers' => [
393					'Content-Type' => 'application/json',
394				],
395				'bodyContents' => json_encode( [
396					'token' => 'TOKEN',
397					'comment' => 'Testing',
398					'source' => 'Lorem Ipsum',
399					'content_model' => CONTENT_MODEL_WIKITEXT,
400				] ),
401			],
402			new MessageValue( 'rest-missing-body-field', [ 'title' ] ),
403		];
404	}
405
406	/**
407	 * @dataProvider provideBodyValidation
408	 */
409	public function testBodyValidation( array $requestData, MessageValue $expectedMessage ) {
410		$request = new RequestData( $requestData );
411
412		$handler = $this->newHandler( [] );
413
414		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
415
416		$this->assertSame( 400, $exception->getCode(), 'HTTP status' );
417		$this->assertInstanceOf( LocalizedHttpException::class, $exception );
418
419		/** @var LocalizedHttpException $exception */
420		$this->assertEquals( $expectedMessage, $exception->getMessageValue() );
421	}
422
423	public function provideHeaderValidation() {
424		yield "bad content type" => [
425			[ // Request data received by CreationHandler
426				'method' => 'POST',
427				'headers' => [
428					'Content-Type' => 'text/plain',
429				],
430				'bodyContents' => json_encode( [
431					'title' => 'Foo',
432					'source' => 'Lorem Ipsum',
433					'comment' => 'Testing',
434					'content_model' => CONTENT_MODEL_WIKITEXT,
435				] ),
436			],
437			415
438		];
439	}
440
441	public function testBodyValidation_extraneousToken() {
442		$requestData = [
443			'method' => 'POST',
444			'pathParams' => [ 'title' => 'Foo' ],
445			'headers' => [
446				'Content-Type' => 'application/json',
447			],
448			'bodyContents' => json_encode( [
449				'title' => 'Foo',
450				'token' => 'TOKEN',
451				'comment' => 'Testing',
452				'source' => 'Lorem Ipsum',
453				'content_model' => 'wikitext'
454			] ),
455		];
456
457		$request = new RequestData( $requestData );
458
459		$handler = $this->newHandler( [], null, true );
460
461		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
462
463		$this->assertSame( 400, $exception->getCode(), 'HTTP status' );
464		$this->assertInstanceOf( LocalizedHttpException::class, $exception );
465
466		$expectedMessage = new MessageValue( 'rest-extraneous-csrf-token' );
467		$this->assertEquals( $expectedMessage, $exception->getMessageValue() );
468	}
469
470	/**
471	 * @dataProvider provideHeaderValidation
472	 */
473	public function testHeaderValidation( array $requestData, $expectedStatus ) {
474		$request = new RequestData( $requestData );
475
476		$handler = $this->newHandler( [] );
477
478		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
479
480		$this->assertSame( $expectedStatus, $exception->getCode(), 'HTTP status' );
481	}
482
483	public function provideErrorMapping() {
484		yield "missingtitle" => [
485			new ApiUsageException( null, Status::newFatal( 'apierror-missingtitle' ) ),
486			new LocalizedHttpException( new MessageValue( 'apierror-missingtitle' ), 404 ),
487		];
488		yield "protectedpage" => [
489			new ApiUsageException( null, Status::newFatal( 'apierror-protectedpage' ) ),
490			new LocalizedHttpException( new MessageValue( 'apierror-protectedpage' ), 403 ),
491		];
492		yield "articleexists" => [
493			new ApiUsageException( null, Status::newFatal( 'apierror-articleexists' ) ),
494			new LocalizedHttpException( new MessageValue( 'apierror-articleexists' ), 409 ),
495		];
496		yield "editconflict" => [
497			new ApiUsageException( null, Status::newFatal( 'apierror-editconflict' ) ),
498			new LocalizedHttpException( new MessageValue( 'apierror-editconflict' ), 409 ),
499		];
500		yield "ratelimited" => [
501			new ApiUsageException( null, Status::newFatal( 'apierror-ratelimited' ) ),
502			new LocalizedHttpException( new MessageValue( 'apierror-ratelimited' ), 429 ),
503		];
504		yield "badtoken" => [
505			new ApiUsageException(
506				null,
507				Status::newFatal( 'apierror-badtoken', [ 'plaintext' => 'BAD' ] )
508			),
509			new LocalizedHttpException(
510				new MessageValue(
511					'apierror-badtoken',
512					[ new ScalarParam( ParamType::PLAINTEXT, 'BAD' ) ]
513				), 403
514			),
515		];
516
517		// Unmapped errors should be passed through with a status 400.
518		yield "no-direct-editing" => [
519			new ApiUsageException( null, Status::newFatal( 'apierror-no-direct-editing' ) ),
520			new LocalizedHttpException( new MessageValue( 'apierror-no-direct-editing' ), 400 ),
521		];
522		yield "badformat" => [
523			new ApiUsageException( null, Status::newFatal( 'apierror-badformat' ) ),
524			new LocalizedHttpException( new MessageValue( 'apierror-badformat' ), 400 ),
525		];
526		yield "emptypage" => [
527			new ApiUsageException( null, Status::newFatal( 'apierror-emptypage' ) ),
528			new LocalizedHttpException( new MessageValue( 'apierror-emptypage' ), 400 ),
529		];
530	}
531
532	/**
533	 * @dataProvider provideErrorMapping
534	 */
535	public function testErrorMapping(
536		ApiUsageException $apiUsageException,
537		HttpException $expectedHttpException
538	) {
539		$requestData = [ // Request data received by CreationHandler
540			'method' => 'POST',
541			'headers' => [
542				'Content-Type' => 'application/json',
543			],
544			'bodyContents' => json_encode( [
545				'title' => 'Foo',
546				'source' => 'Lorem Ipsum',
547				'comment' => 'Testing',
548				'content_model' => CONTENT_MODEL_WIKITEXT,
549			] ),
550		];
551		$request = new RequestData( $requestData );
552
553		$handler = $this->newHandler( [], $apiUsageException );
554
555		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
556
557		$this->assertSame( $expectedHttpException->getMessage(), $exception->getMessage() );
558		$this->assertSame( $expectedHttpException->getCode(), $exception->getCode(), 'HTTP status' );
559
560		$errorData = $exception->getErrorData();
561		if ( $expectedHttpException->getErrorData() ) {
562			foreach ( $expectedHttpException->getErrorData() as $key => $value ) {
563				$this->assertSame( $value, $errorData[$key], 'Error data key $key' );
564			}
565		}
566
567		if ( $expectedHttpException instanceof LocalizedHttpException ) {
568			/** @var LocalizedHttpException $exception */
569			$this->assertEquals(
570				$expectedHttpException->getMessageValue(),
571				$exception->getMessageValue()
572			);
573		}
574	}
575
576}
577