3 namespace MediaWiki\Tests\Api
;
5 use MediaWiki\Content\CssContent
;
6 use MediaWiki\Content\WikitextContent
;
7 use MediaWiki\Storage\PageEditStash
;
8 use MediaWiki\Tests\User\TempUser\TempUserTestTrait
;
9 use MediaWiki\Title\Title
;
10 use MediaWiki\User\User
;
11 use MediaWiki\User\UserIdentity
;
12 use MediaWiki\User\UserRigorOptions
;
13 use Psr\Log\NullLogger
;
15 use Wikimedia\ObjectCache\HashBagOStuff
;
16 use Wikimedia\Stats\StatsFactory
;
17 use Wikimedia\TestingAccessWrapper
;
18 use Wikimedia\Timestamp\ConvertibleTimestamp
;
21 * @covers \MediaWiki\Api\ApiStashEdit
22 * @covers \MediaWiki\Storage\PageEditStash
26 * @todo Expand tests for temporary users
28 class ApiStashEditTest
extends ApiTestCase
{
29 use TempUserTestTrait
;
31 private const CLASS_NAME
= 'ApiStashEditTest';
33 protected function setUp(): void
{
35 // Hack to make user edit tracker survive service reset.
36 // We want it's cache to persist within tests run, otherwise
37 // incorrect in-process cache is being reset, and we get outdated
39 $this->setService( 'UserEditTracker', $this->getServiceContainer()
40 ->getUserEditTracker() );
41 $this->setService( 'PageEditStash', new PageEditStash(
42 new HashBagOStuff( [] ),
43 $this->getServiceContainer()->getConnectionProvider(),
45 StatsFactory
::newNull(),
46 $this->getServiceContainer()->getUserEditTracker(),
47 $this->getServiceContainer()->getUserFactory(),
48 $this->getServiceContainer()->getWikiPageFactory(),
49 $this->getServiceContainer()->getHookContainer(),
50 PageEditStash
::INITIATOR_USER
55 * Make a stashedit API call with suitable default parameters
57 * @param array $params Query parameters for API request. All are optional and will have
58 * sensible defaults filled in. To make a parameter actually not passed, set to null.
59 * @param User|null $user User to do the request
60 * @param string $expectedResult 'stashed', 'editconflict'
63 protected function doStash(
64 array $params = [], ?User
$user = null, $expectedResult = 'stashed'
66 $params = array_merge( [
67 'action' => 'stashedit',
68 'title' => self
::CLASS_NAME
,
69 'contentmodel' => 'wikitext',
70 'contentformat' => 'text/x-wiki',
73 if ( !array_key_exists( 'text', $params ) &&
74 !array_key_exists( 'stashedtexthash', $params )
76 $params['text'] = 'Content';
78 foreach ( $params as $key => $val ) {
79 if ( $val === null ) {
80 unset( $params[$key] );
84 if ( isset( $params['text'] ) ) {
85 $expectedText = $params['text'];
86 } elseif ( isset( $params['stashedtexthash'] ) ) {
87 $expectedText = $this->getStashedText( $params['stashedtexthash'] );
89 if ( isset( $expectedText ) ) {
90 $expectedText = rtrim( str_replace( "\r\n", "\n", $expectedText ) );
91 $expectedHash = sha1( $expectedText );
92 $origText = $this->getStashedText( $expectedHash );
95 $res = $this->doApiRequestWithToken( $params, null, $user );
97 $this->assertSame( $expectedResult, $res[0]['stashedit']['status'] );
98 $this->assertCount( $expectedResult === 'stashed' ?
2 : 1, $res[0]['stashedit'] );
100 if ( $expectedResult === 'stashed' ) {
101 $hash = $res[0]['stashedit']['texthash'];
103 $this->assertSame( $expectedText, $this->getStashedText( $hash ) );
105 $this->assertSame( $expectedHash, $hash );
107 if ( isset( $params['stashedtexthash'] ) ) {
108 $this->assertSame( $expectedHash, $params['stashedtexthash'] );
111 $this->assertSame( $origText, $this->getStashedText( $expectedHash ) );
114 $this->assertArrayNotHasKey( 'warnings', $res[0] );
120 * Return the text stashed for $hash.
122 * @param string $hash
125 protected function getStashedText( $hash ) {
126 return $this->getServiceContainer()->getPageEditStash()->fetchInputText( $hash );
130 * Return a key that can be passed to the cache to obtain a stashed edit object.
132 * @param string $title Title of page
133 * @param string $text Content of edit
134 * @param User|null $user User who made edit
137 protected function getStashKey( $title = self
::CLASS_NAME
, $text = 'Content', ?User
$user = null ) {
138 $titleObj = Title
::newFromText( $title );
139 $content = new WikitextContent( $text );
141 $user = $this->getTestSysop()->getUser();
143 $editStash = TestingAccessWrapper
::newFromObject(
144 $this->getServiceContainer()->getPageEditStash() );
146 return $editStash->getStashKey( $titleObj, $editStash->getContentHash( $content ), $user );
149 public function testBasicEdit() {
153 public function testBot() {
154 // @todo This restriction seems arbitrary, is there any good reason to keep it?
155 $this->expectApiErrorCode( 'botsnotsupported' );
157 $this->doStash( [], $this->getTestUser( [ 'bot' ] )->getUser() );
160 public function testUnrecognizedFormat() {
161 $this->expectApiErrorCode( 'badmodelformat' );
163 $this->doStash( [ 'contentformat' => 'application/json' ] );
166 public function testMissingTextAndStashedTextHash() {
167 $this->expectApiErrorCode( 'missingparam' );
168 $this->doStash( [ 'text' => null ] );
171 public function testStashedTextHash() {
172 $res = $this->doStash();
174 $this->doStash( [ 'stashedtexthash' => $res[0]['stashedit']['texthash'] ] );
177 public function testMalformedStashedTextHash() {
178 $this->expectApiErrorCode( 'missingtext' );
179 $this->doStash( [ 'stashedtexthash' => 'abc' ] );
182 public function testMissingStashedTextHash() {
183 $this->expectApiErrorCode( 'missingtext' );
184 $this->doStash( [ 'stashedtexthash' => str_repeat( '0', 40 ) ] );
187 public function testHashNormalization() {
188 $res1 = $this->doStash( [ 'text' => "a\r\nb\rc\nd \t\n\r" ] );
189 $res2 = $this->doStash( [ 'text' => "a\nb\rc\nd" ] );
191 $this->assertSame( $res1[0]['stashedit']['texthash'], $res2[0]['stashedit']['texthash'] );
192 $this->assertSame( "a\nb\rc\nd",
193 $this->getStashedText( $res1[0]['stashedit']['texthash'] ) );
196 public function testNonexistentBaseRevId() {
197 $this->expectApiErrorCode( 'nosuchrevid' );
199 $name = ucfirst( __FUNCTION__
);
200 $this->editPage( $name, '' );
201 $this->doStash( [ 'title' => $name, 'baserevid' => pow( 2, 31 ) - 1 ] );
204 public function testPageWithNoRevisions() {
205 $name = ucfirst( __FUNCTION__
);
206 $revRecord = $this->editPage( $name, '' )->getNewRevision();
208 $this->expectApiErrorCode( 'missingrev' );
210 // Corrupt the database. @todo Does the API really need to fail gracefully for this case?
211 $this->getDb()->newUpdateQueryBuilder()
213 ->set( [ 'page_latest' => 0 ] )
214 ->where( [ 'page_id' => $revRecord->getPageId() ] )
215 ->caller( __METHOD__
)->execute();
217 $this->doStash( [ 'title' => $name, 'baserevid' => $revRecord->getId() ] );
220 public function testExistingPage() {
221 $name = ucfirst( __FUNCTION__
);
222 $revRecord = $this->editPage( $name, '' )->getNewRevision();
224 $this->doStash( [ 'title' => $name, 'baserevid' => $revRecord->getId() ] );
227 public function testInterveningEdit() {
228 $this->markTestSkippedIfNoDiff3();
230 $name = ucfirst( __FUNCTION__
);
231 $oldRevRecord = $this->editPage( $name, "A\n\nB" )->getNewRevision();
232 $this->editPage( $name, "A\n\nC" );
236 'baserevid' => $oldRevRecord->getId(),
241 public function testEditConflict() {
242 $name = ucfirst( __FUNCTION__
);
243 $oldRevRecord = $this->editPage( $name, 'A' )->getNewRevision();
244 $this->editPage( $name, 'B' );
248 'baserevid' => $oldRevRecord->getId(),
250 ], null, 'editconflict' );
253 public function testMidEditContentModelMismatch() {
254 $name = ucfirst( __FUNCTION__
);
255 $title = Title
::makeTitle( NS_MAIN
, $name );
256 $content = new CssContent( 'Css' );
257 $performer = $this->getTestSysop()->getAuthority();
258 $revRecord = $this->editPage(
267 new WikitextContent( 'Text' ),
273 $this->expectApiErrorCode( 'contentmodel-mismatch' );
274 $this->doStash( [ 'title' => $title->getPrefixedText(), 'baserevid' => $revRecord->getId() ] );
277 public function testDeletedRevision() {
278 $name = ucfirst( __FUNCTION__
);
279 $oldRevRecord = $this->editPage( $name, 'A' )->getNewRevision();
280 $this->editPage( $name, 'B' );
282 $this->expectApiErrorCode( 'missingrev' );
284 $this->revisionDelete( $oldRevRecord );
288 'baserevid' => $oldRevRecord->getId(),
293 public function testDeletedRevisionSection() {
294 $name = ucfirst( __FUNCTION__
);
295 $oldRevRecord = $this->editPage( $name, 'A' )->getNewRevision();
296 $this->editPage( $name, 'B' );
298 $this->expectApiErrorCode( 'replacefailed' );
300 $this->revisionDelete( $oldRevRecord );
304 'baserevid' => $oldRevRecord->getId(),
310 public function testPingLimiter() {
311 $this->mergeMwGlobalArrayValue( 'wgRateLimits',
312 [ 'stashedit' => [ '&can-bypass' => false, 'user' => [ 1, 60 ] ] ] );
314 $this->doStash( [ 'text' => 'A' ] );
316 $this->doStash( [ 'text' => 'B' ], null, 'ratelimited' );
320 * Shortcut for calling PageStashEdit::checkCache() without
321 * having to create Titles and Contents in every test.
323 * @param UserIdentity $user
324 * @param string $text The text of the article
325 * @return stdClass|bool Return value of PageStashEdit::checkCache(), false if not in cache
327 protected function doCheckCache( UserIdentity
$user, $text = 'Content' ) {
328 return $this->getServiceContainer()->getPageEditStash()->checkCache(
329 Title
::makeTitle( NS_MAIN
, 'ApiStashEditTest' ),
330 new WikitextContent( $text ),
335 public function testCheckCache() {
336 $user = $this->getMutableTestUser()->getUser();
337 $permissionManager = $this->getServiceContainer()->getPermissionManager();
338 $userGroupManager = $this->getServiceContainer()->getUserGroupManager();
340 $this->doStash( [], $user );
342 $this->assertInstanceOf( stdClass
::class, $this->doCheckCache( $user ) );
344 // Another user doesn't see the cache
346 $this->doCheckCache( $this->getTestUser()->getUser() ),
347 'Cache is user-specific'
350 // Nor does the original one if they become a bot
351 $userGroupManager->addUserToGroup( $user, 'bot' );
352 $permissionManager->invalidateUsersRightsCache();
354 $this->doCheckCache( $user ),
355 "We assume bots don't have cache entries"
358 // But other groups are okay
359 $userGroupManager->removeUserFromGroup( $user, 'bot' );
360 $userGroupManager->addUserToGroup( $user, 'sysop' );
361 $permissionManager->invalidateUsersRightsCache();
362 $this->assertInstanceOf( stdClass
::class, $this->doCheckCache( $user ) );
365 public function testCheckCacheAnon() {
366 $this->disableAutoCreateTempUser();
367 $user = $this->getServiceContainer()->getUserFactory()->newFromName( '174.5.4.6', UserRigorOptions
::RIGOR_NONE
);
369 $this->doStash( [], $user );
371 $this->assertInstanceOf( stdClass
::class, $this->doCheckCache( $user ) );
375 * Stash an edit some time in the past, for testing expiry and freshness logic.
377 * @param User $user Who's doing the editing
378 * @param string $text What text should be cached
379 * @param int $howOld How many seconds is "old" (we actually set it one second before this)
381 protected function doStashOld(
382 User
$user, $text = 'Content', $howOld = PageEditStash
::PRESUME_FRESH_TTL_SEC
384 ConvertibleTimestamp
::setFakeTime( ConvertibleTimestamp
::now( TS_UNIX
) - $howOld - 1 );
385 $this->doStash( [ 'text' => $text ], $user );
388 public function testCheckCacheOldNoEdits() {
389 $user = $this->getTestSysop()->getUser();
391 $this->doStashOld( $user );
393 // Should still be good, because no intervening edits
394 $this->assertInstanceOf( stdClass
::class, $this->doCheckCache( $user ) );
397 public function testCheckCacheOldNoEditsAnon() {
398 $this->disableAutoCreateTempUser();
399 // Specify a made-up IP address to make sure no edits are lying around
400 $user = $this->getServiceContainer()->getUserFactory()->newFromName( '172.0.2.77', UserRigorOptions
::RIGOR_NONE
);
402 $this->doStashOld( $user );
404 // Should still be good, because no intervening edits
405 $this->assertInstanceOf( stdClass
::class, $this->doCheckCache( $user ) );
408 public function testCheckCacheInterveningEdits() {
409 $user = $this->getTestSysop()->getUser();
411 $this->doStashOld( $user );
413 // Now let's also increment our editcount
414 $this->editPage( ucfirst( __FUNCTION__
), '', '', NS_MAIN
, $user );
416 $user->clearInstanceCache();
417 $this->assertFalse( $this->doCheckCache( $user ),
418 "Cache should be invalidated when it's old and the user has an intervening edit" );
422 * @dataProvider signatureProvider
423 * @param string $text Which signature to test (~~~, ~~~~, or ~~~~~)
424 * @param int $ttl Expected TTL in seconds
426 public function testSignatureTtl( $text, $ttl ) {
427 $this->doStash( [ 'text' => $text ] );
429 $editStash = TestingAccessWrapper
::newFromObject(
430 $this->getServiceContainer()->getPageEditStash() );
431 $cache = $editStash->cache
;
432 $key = $this->getStashKey( self
::CLASS_NAME
, $text );
434 $wrapper = TestingAccessWrapper
::newFromObject( $cache );
436 $this->assertEqualsWithDelta( $ttl, $wrapper->bag
[$key][HashBagOStuff
::KEY_EXP
] - time(), 1 );
439 public function signatureProvider() {
441 '~~~' => [ '~~~', PageEditStash
::MAX_SIGNATURE_TTL
],
442 '~~~~' => [ '~~~~', PageEditStash
::MAX_SIGNATURE_TTL
],
443 '~~~~~' => [ '~~~~~', PageEditStash
::MAX_SIGNATURE_TTL
],
447 public function testIsInternal() {
448 $res = $this->doApiRequest( [
449 'action' => 'paraminfo',
450 'modules' => 'stashedit',
453 $this->assertCount( 1, $res[0]['paraminfo']['modules'] );
454 $this->assertSame( true, $res[0]['paraminfo']['modules'][0]['internal'] );
457 public function testBusy() {
458 // @todo This doesn't work because both lock acquisitions are in the same MySQL session, so
459 // they don't conflict. How do I open a different session?
460 $this->markTestSkipped();
462 $key = $this->getStashKey();
463 $this->getDb()->lock( $key, __METHOD__
, 0 );
465 $this->doStash( [], null, 'busy' );
467 $this->getDb()->unlock( $key, __METHOD__
);