1<?php
2
3namespace MediaWiki\Tests\Rest\Handler;
4
5use ApiUsageException;
6use FormatJson;
7use HashConfig;
8use MediaWiki\Content\IContentHandlerFactory;
9use MediaWiki\Rest\Handler\UpdateHandler;
10use MediaWiki\Rest\HttpException;
11use MediaWiki\Rest\LocalizedHttpException;
12use MediaWiki\Rest\RequestData;
13use MediaWiki\Revision\RevisionLookup;
14use MediaWiki\Storage\MutableRevisionRecord;
15use MediaWiki\Storage\SlotRecord;
16use MediaWikiTitleCodec;
17use MockTitleTrait;
18use PHPUnit\Framework\MockObject\MockObject;
19use Status;
20use Title;
21use Wikimedia\Message\MessageValue;
22use Wikimedia\Message\ParamType;
23use Wikimedia\Message\ScalarParam;
24use WikitextContent;
25use WikitextContentHandler;
26
27/**
28 * @covers \MediaWiki\Rest\Handler\UpdateHandler
29 */
30class UpdateHandlerTest extends \MediaWikiLangTestCase {
31
32	use ActionModuleBasedHandlerTestTrait;
33	use MockTitleTrait;
34
35	private function newHandler( $resultData, $throwException = null, $csrfSafe = false ) {
36		$config = new HashConfig( [
37			'RightsUrl' => 'https://creativecommons.org/licenses/by-sa/4.0/',
38			'RightsText' => 'CC-BY-SA 4.0'
39		] );
40
41		/** @var IContentHandlerFactory|MockObject $contentHandlerFactory */
42		$contentHandlerFactory =
43			$this->createNoOpMock(
44				IContentHandlerFactory::class,
45				[ 'isDefinedModel', 'getContentHandler' ]
46			);
47
48		$contentHandlerFactory
49			->method( 'isDefinedModel' )
50			->willReturnMap( [
51				[ CONTENT_MODEL_WIKITEXT, true ],
52			] );
53
54		$contentHandlerFactory
55			->method( 'getContentHandler' )
56			->willReturn( new WikitextContentHandler() );
57
58		/** @var MockObject|MediaWikiTitleCodec $titleCodec */
59		$titleCodec = $this->getMockBuilder( MediaWikiTitleCodec::class )
60			->disableOriginalConstructor()
61			->onlyMethods( [ 'formatTitle', 'splitTitleString' ] )
62			->getMock();
63
64		$titleCodec->method( 'formatTitle' )
65			->willReturnCallback( static function ( $namespace, $text ) {
66				return "ns:$namespace:" . ucfirst( $text );
67			} );
68		$titleCodec->method( 'splitTitleString' )
69			->willReturnCallback( static function ( $text ) {
70				return [
71					'interwiki' => '',
72					'fragment' => '',
73					'namespace' => 0,
74					'dbkey' => str_replace( ' ', '_', $text )
75				];
76			} );
77
78		/** @var RevisionLookup|MockObject $revisionLookup */
79		$revisionLookup = $this->createNoOpMock(
80			RevisionLookup::class,
81			[ 'getRevisionById', 'getRevisionByTitle' ]
82		);
83		$revisionLookup->method( 'getRevisionById' )
84			->willReturnCallback( function ( $id ) {
85				$title = $this->makeMockTitle( __CLASS__ );
86				$rev = new MutableRevisionRecord( $title );
87				$rev->setId( $id );
88				$rev->setContent( SlotRecord::MAIN, new WikitextContent( "Content of revision $id" ) );
89				$rev->setTimestamp( '2020-01-01T01:02:03Z' );
90				return $rev;
91			} );
92		$revisionLookup->method( 'getRevisionByTitle' )
93			->willReturnCallback( static function ( $title ) {
94				$rev = new MutableRevisionRecord( Title::castFromLinkTarget( $title ) );
95				$rev->setId( 1234 );
96				$rev->setContent( SlotRecord::MAIN, new WikitextContent( "Current content of $title" ) );
97				$rev->setTimestamp( '2020-01-01T01:02:03Z' );
98				return $rev;
99			} );
100
101		$handler = new UpdateHandler(
102			$config,
103			$contentHandlerFactory,
104			$titleCodec,
105			$titleCodec,
106			$revisionLookup
107		);
108
109		$apiMain = $this->getApiMain( $csrfSafe );
110		$dummyModule = $this->getDummyApiModule( $apiMain, 'edit', $resultData, $throwException );
111
112		$handler->setApiMain( $apiMain );
113		$handler->overrideActionModule(
114			'edit',
115			'action',
116			$dummyModule
117		);
118
119		return $handler;
120	}
121
122	public function provideExecute() {
123		yield "create with token" => [
124			[ // Request data received by UpdateHandler
125				'method' => 'PUT',
126				'pathParams' => [ 'title' => 'Foo' ],
127				'headers' => [
128					'Content-Type' => 'application/json',
129				],
130				'bodyContents' => json_encode( [
131					'token' => 'TOKEN',
132					'source' => 'Lorem Ipsum',
133					'comment' => 'Testing'
134				] ),
135			],
136			[ // Fake request expected to be passed into ApiEditPage
137				'title' => 'Foo',
138				'text' => 'Lorem Ipsum',
139				'summary' => 'Testing',
140				'createonly' => '1',
141				'token' => 'TOKEN',
142			],
143			[ // Mock response returned by ApiEditPage
144				"edit" => [
145					"new" => true,
146					"result" => "Success",
147					"pageid" => 94542,
148					"title" => "Foo",
149					"contentmodel" => "wikitext",
150					"oldrevid" => 0,
151					"newrevid" => 371707,
152					"newtimestamp" => "2018-12-18T16:59:42Z",
153				]
154			],
155			[ // Response expected to be generated by UpdateHandler
156				'id' => 94542,
157				'title' => 'ns:0:Foo',
158				'key' => 'ns:0:Foo',
159				'content_model' => 'wikitext',
160				'latest' => [
161					'id' => 371707,
162					'timestamp' => "2018-12-18T16:59:42Z"
163				],
164				'license' => [
165					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
166					'title' => 'CC-BY-SA 4.0'
167				],
168				'source' => 'Content of revision 371707'
169			],
170			false
171		];
172
173		yield "create with model" => [
174			[ // Request data received by UpdateHandler
175				'method' => 'POST',
176				'pathParams' => [ 'title' => 'Foo' ],
177				'headers' => [
178					'Content-Type' => 'application/json',
179				],
180				'bodyContents' => json_encode( [
181					'token' => 'TOKEN',
182					'source' => 'Lorem Ipsum',
183					'comment' => 'Testing',
184					'content_model' => CONTENT_MODEL_WIKITEXT,
185				] ),
186			],
187			[ // Fake request expected to be passed into ApiEditPage
188				'title' => 'Foo',
189				'text' => 'Lorem Ipsum',
190				'summary' => 'Testing',
191				'contentmodel' => 'wikitext',
192				'createonly' => '1',
193				'token' => 'TOKEN',
194			],
195			[ // Mock response returned by ApiEditPage
196				"edit" => [
197					"new" => true,
198					"result" => "Success",
199					"pageid" => 94542,
200					"title" => "Foo",
201					"contentmodel" => "wikitext",
202					"oldrevid" => 0,
203					"newrevid" => 371707,
204					"newtimestamp" => "2018-12-18T16:59:42Z",
205				]
206			],
207			[ // Response expected to be generated by UpdateHandler
208				'id' => 94542,
209				'title' => 'ns:0:Foo',
210				'key' => 'ns:0:Foo',
211				'content_model' => 'wikitext',
212				'latest' => [
213					'id' => 371707,
214					'timestamp' => "2018-12-18T16:59:42Z"
215				],
216				'license' => [
217					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
218					'title' => 'CC-BY-SA 4.0'
219				],
220				'source' => 'Content of revision 371707'
221			],
222			false
223		];
224
225		yield "update with token" => [
226			[ // Request data received by UpdateHandler
227				'method' => 'PUT',
228				'pathParams' => [ 'title' => 'foo bar' ],
229				'headers' => [
230					'Content-Type' => 'application/json',
231				],
232				'bodyContents' => json_encode( [
233					'token' => 'TOKEN',
234					'source' => 'Lorem Ipsum',
235					'comment' => 'Testing',
236					'latest' => [ 'id' => 789123 ],
237				] ),
238			],
239			[ // Fake request expected to be passed into ApiEditPage
240				'title' => 'foo bar',
241				'text' => 'Lorem Ipsum',
242				'summary' => 'Testing',
243				'nocreate' => '1',
244				'baserevid' => '789123',
245				'token' => 'TOKEN',
246			],
247			[ // Mock response returned by ApiEditPage
248				"edit" => [
249					"result" => "Success",
250					"pageid" => 94542,
251					"title" => "Foo_bar",
252					"contentmodel" => "wikitext",
253					"oldrevid" => 371705,
254					"newrevid" => 371707,
255					"newtimestamp" => "2018-12-18T16:59:42Z",
256				]
257			],
258			[ // Response expected to be generated by UpdateHandler
259				'id' => 94542,
260				'content_model' => 'wikitext',
261				'title' => 'ns:0:Foo bar',
262				'key' => 'ns:0:Foo_bar',
263				'latest' => [
264					'id' => 371707,
265					'timestamp' => "2018-12-18T16:59:42Z"
266				],
267				'license' => [
268					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
269					'title' => 'CC-BY-SA 4.0'
270				],
271				'source' => 'Content of revision 371707'
272			],
273			false
274		];
275
276		yield "update with model" => [
277			[ // Request data received by UpdateHandler
278				'method' => 'POST',
279				'pathParams' => [ 'title' => 'foo bar' ],
280				'headers' => [
281					'Content-Type' => 'application/json',
282				],
283				'bodyContents' => json_encode( [
284					'source' => 'Lorem Ipsum',
285					'comment' => 'Testing',
286					'content_model' => CONTENT_MODEL_WIKITEXT,
287					'latest' => [ 'id' => 789123 ],
288				] ),
289			],
290			[ // Fake request expected to be passed into ApiEditPage
291				'title' => 'foo bar',
292				'text' => 'Lorem Ipsum',
293				'summary' => 'Testing',
294				'contentmodel' => 'wikitext',
295				'nocreate' => '1',
296				'baserevid' => '789123',
297				'token' => '+\\',
298			],
299			[ // Mock response returned by ApiEditPage
300				"edit" => [
301					"result" => "Success",
302					"pageid" => 94542,
303					"title" => "Foo_bar",
304					"contentmodel" => "wikitext",
305					"oldrevid" => 371705,
306					"newrevid" => 371707,
307					"newtimestamp" => "2018-12-18T16:59:42Z",
308				]
309			],
310			[ // Response expected to be generated by UpdateHandler
311				'id' => 94542,
312				'content_model' => 'wikitext',
313				'title' => 'ns:0:Foo bar',
314				'key' => 'ns:0:Foo_bar',
315				'latest' => [
316					'id' => 371707,
317					'timestamp' => "2018-12-18T16:59:42Z"
318				],
319				'license' => [
320					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
321					'title' => 'CC-BY-SA 4.0'
322				],
323				'source' => 'Content of revision 371707'
324			],
325			true
326		];
327
328		yield "update without token" => [
329			[ // Request data received by UpdateHandler
330				'method' => 'PUT',
331				'pathParams' => [ 'title' => 'Foo' ],
332				'headers' => [
333					'Content-Type' => 'application/json',
334				],
335				'bodyContents' => json_encode( [
336					'source' => 'Lorem Ipsum',
337					'comment' => 'Testing',
338					'content_model' => CONTENT_MODEL_WIKITEXT,
339					'latest' => [ 'id' => 789123 ],
340				] ),
341			],
342			[ // Fake request expected to be passed into ApiEditPage
343				'title' => 'Foo',
344				'text' => 'Lorem Ipsum',
345				'summary' => 'Testing',
346				'contentmodel' => 'wikitext',
347				'nocreate' => '1',
348				'baserevid' => '789123',
349				'token' => '+\\', // use known-good token for current user (anon)
350			],
351			[ // Mock response returned by ApiEditPage
352				"edit" => [
353					"result" => "Success",
354					"pageid" => 94542,
355					"title" => "Foo",
356					"contentmodel" => "wikitext",
357					"oldrevid" => 371705,
358					"newrevid" => 371707,
359					"newtimestamp" => "2018-12-18T16:59:42Z",
360				]
361			],
362			[ // Response expected to be generated by UpdateHandler
363				'id' => 94542,
364				'content_model' => 'wikitext',
365				'latest' => [
366					'id' => 371707,
367					'timestamp' => "2018-12-18T16:59:42Z"
368				],
369				'license' => [
370					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
371					'title' => 'CC-BY-SA 4.0'
372				],
373			],
374			true
375		];
376
377		yield "null-edit (unchanged)" => [
378			[ // Request data received by UpdateHandler
379				'method' => 'PUT',
380				'pathParams' => [ 'title' => 'Foo' ],
381				'headers' => [
382					'Content-Type' => 'application/json',
383				],
384				'bodyContents' => json_encode( [
385					'source' => 'Lorem Ipsum',
386					'comment' => 'Testing',
387					'content_model' => CONTENT_MODEL_WIKITEXT,
388					'latest' => [ 'id' => 789123 ],
389				] ),
390			],
391			[ // Fake request expected to be passed into ApiEditPage
392				'title' => 'Foo',
393				'text' => 'Lorem Ipsum',
394				'summary' => 'Testing',
395				'contentmodel' => 'wikitext',
396				'nocreate' => '1',
397				'baserevid' => '789123',
398				'token' => '+\\', // use known-good token for current user (anon)
399			],
400			[ // Mock response returned by ApiEditPage
401				"edit" => [
402					"result" => "Success",
403					"pageid" => 94542,
404					"title" => "Foo",
405					"contentmodel" => "wikitext",
406					"nochange" => "", // null-edit!
407			]
408			],
409			[ // Response expected to be generated by UpdateHandler
410				'id' => 94542,
411				'content_model' => 'wikitext',
412				'latest' => [
413					'id' => 1234, // ID of current rev, as defined in newHandler()
414					'timestamp' => '2020-01-01T01:02:03Z' // see fake RevisionStore in newHandler()
415				],
416				'license' => [
417					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
418					'title' => 'CC-BY-SA 4.0'
419				],
420			],
421			true
422		];
423	}
424
425	/**
426	 * @dataProvider provideExecute
427	 */
428	public function testExecute(
429		$requestData,
430		$expectedActionParams,
431		$actionResult,
432		$expectedResponse,
433		$csrfSafe
434	) {
435		$request = new RequestData( $requestData );
436
437		$handler = $this->newHandler( $actionResult, null, $csrfSafe );
438
439		$responseData = $this->executeHandlerAndGetBodyData( $handler, $request );
440
441		// Check parameters passed to ApiEditPage by UpdateHandler based on $requestData
442		foreach ( $expectedActionParams as $key => $value ) {
443			$this->assertSame(
444				$value,
445				$handler->getApiMain()->getVal( $key ),
446				"ApiEditPage param: $key"
447			);
448		}
449
450		// Check response that UpdateHandler created after receiving $actionResult from ApiEditPage
451		foreach ( $expectedResponse as $key => $value ) {
452			$this->assertArrayHasKey( $key, $responseData );
453			$this->assertSame(
454				$value,
455				$responseData[ $key ],
456				"UpdateHandler response field: $key"
457			);
458		}
459	}
460
461	public function provideBodyValidation() {
462		yield "missing source field" => [
463			[ // Request data received by UpdateHandler
464				'method' => 'PUT',
465				'pathParams' => [ 'title' => 'Foo' ],
466				'headers' => [
467					'Content-Type' => 'application/json',
468				],
469				'bodyContents' => json_encode( [
470					'token' => 'TOKEN',
471					'comment' => 'Testing',
472					'content_model' => CONTENT_MODEL_WIKITEXT,
473				] ),
474			],
475			new MessageValue( 'rest-missing-body-field', [ 'source' ] ),
476		];
477		yield "missing comment field" => [
478			[ // Request data received by UpdateHandler
479				'method' => 'PUT',
480				'pathParams' => [ 'title' => 'Foo' ],
481				'headers' => [
482					'Content-Type' => 'application/json',
483				],
484				'bodyContents' => json_encode( [
485					'token' => 'TOKEN',
486					'source' => 'Lorem Ipsum',
487					'content_model' => CONTENT_MODEL_WIKITEXT,
488				] ),
489			],
490			new MessageValue( 'rest-missing-body-field', [ 'comment' ] ),
491		];
492	}
493
494	/**
495	 * @dataProvider provideBodyValidation
496	 */
497	public function testBodyValidation( array $requestData, MessageValue $expectedMessage ) {
498		$request = new RequestData( $requestData );
499
500		$handler = $this->newHandler( [] );
501
502		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
503
504		$this->assertSame( 400, $exception->getCode(), 'HTTP status' );
505		$this->assertInstanceOf( LocalizedHttpException::class, $exception );
506
507		/** @var LocalizedHttpException $exception */
508		$this->assertEquals( $expectedMessage, $exception->getMessageValue() );
509	}
510
511	public function testBodyValidation_extraneousToken() {
512		$requestData = [
513			'method' => 'PUT',
514			'pathParams' => [ 'title' => 'Foo' ],
515			'headers' => [
516				'Content-Type' => 'application/json',
517			],
518			'bodyContents' => json_encode( [
519				'token' => 'TOKEN',
520				'comment' => 'Testing',
521				'source' => 'Lorem Ipsum',
522				'content_model' => 'wikitext'
523			] ),
524		];
525
526		$request = new RequestData( $requestData );
527
528		$handler = $this->newHandler( [], null, true );
529
530		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
531
532		$this->assertSame( 400, $exception->getCode(), 'HTTP status' );
533		$this->assertInstanceOf( LocalizedHttpException::class, $exception );
534
535		$expectedMessage = new MessageValue( 'rest-extraneous-csrf-token' );
536		$this->assertEquals( $expectedMessage, $exception->getMessageValue() );
537	}
538
539	public function provideHeaderValidation() {
540		yield "bad content type" => [
541			[ // Request data received by UpdateHandler
542				'method' => 'PUT',
543				'pathParams' => [ 'title' => 'Foo' ],
544				'headers' => [
545					'Content-Type' => 'text/plain',
546				],
547				'bodyContents' => json_encode( [
548					'token' => 'TOKEN',
549					'source' => 'Lorem Ipsum',
550					'comment' => 'Testing',
551					'content_model' => CONTENT_MODEL_WIKITEXT,
552				] ),
553			],
554			415
555		];
556	}
557
558	/**
559	 * @dataProvider provideHeaderValidation
560	 */
561	public function testHeaderValidation( array $requestData, $expectedStatus ) {
562		$request = new RequestData( $requestData );
563
564		$handler = $this->newHandler( [] );
565
566		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
567
568		$this->assertSame( $expectedStatus, $exception->getCode(), 'HTTP status' );
569	}
570
571	public function provideErrorMapping() {
572		yield "missingtitle" => [
573			new ApiUsageException( null, Status::newFatal( 'apierror-missingtitle' ) ),
574			new LocalizedHttpException( new MessageValue( 'apierror-missingtitle' ), 404 ),
575		];
576		yield "protectedpage" => [
577			new ApiUsageException( null, Status::newFatal( 'apierror-protectedpage' ) ),
578			new LocalizedHttpException( new MessageValue( 'apierror-protectedpage' ), 403 ),
579		];
580		yield "articleexists" => [
581			new ApiUsageException( null, Status::newFatal( 'apierror-articleexists' ) ),
582			new LocalizedHttpException(
583				new MessageValue( 'rest-update-cannot-create-page', [ 'Foo' ] ),
584				409
585			),
586		];
587		yield "editconflict" => [
588			new ApiUsageException( null, Status::newFatal( 'apierror-editconflict' ) ),
589			new LocalizedHttpException( new MessageValue( 'apierror-editconflict' ), 409 ),
590		];
591		yield "ratelimited" => [
592			new ApiUsageException( null, Status::newFatal( 'apierror-ratelimited' ) ),
593			new LocalizedHttpException( new MessageValue( 'apierror-ratelimited' ), 429 ),
594		];
595		yield "badtoken" => [
596			new ApiUsageException(
597				null,
598				Status::newFatal( 'apierror-badtoken', [ 'plaintext' => 'BAD' ] )
599			),
600			new LocalizedHttpException(
601				new MessageValue(
602					'apierror-badtoken',
603					[ new ScalarParam( ParamType::PLAINTEXT, 'BAD' ) ]
604				), 403
605			),
606		];
607
608		// Unmapped errors should be passed through with a status 400.
609		yield "no-direct-editing" => [
610			new ApiUsageException( null, Status::newFatal( 'apierror-no-direct-editing' ) ),
611			new LocalizedHttpException( new MessageValue( 'apierror-no-direct-editing' ), 400 ),
612		];
613		yield "badformat" => [
614			new ApiUsageException( null, Status::newFatal( 'apierror-badformat' ) ),
615			new LocalizedHttpException( new MessageValue( 'apierror-badformat' ), 400 ),
616		];
617		yield "emptypage" => [
618			new ApiUsageException( null, Status::newFatal( 'apierror-emptypage' ) ),
619			new LocalizedHttpException( new MessageValue( 'apierror-emptypage' ), 400 ),
620		];
621	}
622
623	/**
624	 * @dataProvider provideErrorMapping
625	 */
626	public function testErrorMapping(
627		ApiUsageException $apiUsageException,
628		HttpException $expectedHttpException
629	) {
630		$requestData = [ // Request data received by UpdateHandler
631			'method' => 'POST',
632			'pathParams' => [ 'title' => 'Foo' ],
633			'headers' => [
634				'Content-Type' => 'application/json',
635			],
636			'bodyContents' => json_encode( [
637				'source' => 'Lorem Ipsum',
638				'comment' => 'Testing',
639				'content_model' => CONTENT_MODEL_WIKITEXT,
640			] ),
641		];
642		$request = new RequestData( $requestData );
643
644		$handler = $this->newHandler( [], $apiUsageException );
645
646		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
647
648		$this->assertSame( $expectedHttpException->getMessage(), $exception->getMessage() );
649		$this->assertSame( $expectedHttpException->getCode(), $exception->getCode(), 'HTTP status' );
650
651		$errorData = $exception->getErrorData();
652		if ( $expectedHttpException->getErrorData() ) {
653			foreach ( $expectedHttpException->getErrorData() as $key => $value ) {
654				$this->assertSame( $value, $errorData[$key], 'Error data key $key' );
655			}
656		}
657
658		if ( $expectedHttpException instanceof LocalizedHttpException ) {
659			/** @var LocalizedHttpException $exception */
660			$this->assertEquals(
661				$expectedHttpException->getMessageValue(),
662				$exception->getMessageValue()
663			);
664		}
665	}
666
667	public function testConflictOutput() {
668		$requestData = [ // Request data received by UpdateHandler
669			'method' => 'POST',
670			'pathParams' => [ 'title' => 'Foo' ],
671			'headers' => [
672				'Content-Type' => 'application/json',
673			],
674			'bodyContents' => json_encode( [
675				'latest' => [
676					'id' => 17,
677				],
678				'source' => 'Lorem Ipsum',
679				'comment' => 'Testing'
680			] ),
681		];
682		$request = new RequestData( $requestData );
683
684		$apiUsageException = new ApiUsageException( null, Status::newFatal( 'apierror-editconflict' ) );
685		$handler = $this->newHandler( [], $apiUsageException );
686		$handler->setJsonDiffFunction( [ $this, 'fakeJsonDiff' ] );
687
688		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );
689
690		$this->assertSame( 409, $exception->getCode(), 'HTTP status' );
691
692		$expectedData = [
693			'local' => [
694				'from' => 'Content of revision 17',
695				'to' => 'Lorem Ipsum',
696			],
697			'remote' => [
698				'from' => 'Content of revision 17',
699				'to' => 'Current content of 0:Foo',
700			],
701			'base' => 17,
702			'current' => 1234
703		];
704
705		$errorData = $exception->getErrorData();
706		foreach ( $expectedData as $key => $value ) {
707			$this->assertSame( $value, $errorData[$key], "Error data key $key" );
708		}
709	}
710
711	public function fakeJsonDiff( $fromText, $toText ) {
712		return FormatJson::encode( [
713			'from' => $fromText,
714			'to' => $toText
715		] );
716	}
717
718}
719