wiki.techinc.nl/tests/phpunit/integration/includes/Storage/UndoIntegrationTest.php
Ostrzyciel a1e9038a68 EditPage: don't mark "dirty" undos as undos
This is a follow-up of T259014 which describes issues stemming from
users being able to mark arbitrary edits as undos (mitigation #3).

A "dirty" undo is when the user clicks the undo link and applies some
changes to page's content before saving. By doing so, the user can
set the edit's content to anything, while still marking the edit
with the mw-undo change tag and communicating to extensions that this
was an undo.

With this patch EditPage will only consider edits that had their
content provided by automatic conflict resolution as undos. Anything
other than that can't be reliably considered an undo.

THIS CHANGES THE BEHAVIOR OF EditPage IN A NOTICEABLE MANNER.
Most users should not notice anything different. In my opinion this
change makes EditPage's behavior more sensible and is justified.

Bug: T259014
Change-Id: I9279230303a01461039ae8a4641d9897ce194f73
2020-07-29 10:42:07 +02:00

486 lines
12 KiB
PHP

<?php
namespace MediaWiki\Tests\Storage;
use Article;
use EditPage;
use FauxRequest;
use McrUndoAction;
use MediaWiki\Revision\RevisionStoreRecord;
use MediaWiki\Storage\EditResult;
use MediaWiki\Storage\SlotRecord;
use MediaWikiIntegrationTestCase;
use RequestContext;
use Title;
use User;
use WikiPage;
use WikitextContent;
/**
* Integration tests for undos.
* TODO: This should also test edits with multiple slots.
*
* @covers McrUndoAction
* @covers WikiPage
* @covers EditPage
*
* @group Database
* @group medium
*/
class UndoIntegrationTest extends MediaWikiIntegrationTestCase {
private const PAGE_NAME = 'McrUndoTestPage';
protected function setUp() : void {
parent::setUp();
// Clean up these tables after each test
$this->tablesUsed = [
'page',
'revision',
'comment',
'text',
'content'
];
}
/**
* Creates a new McrUndoAction object for testing.
*
* @param RequestContext $context
* @param Article $article
* @param array $params POST/GET parameters passed to the action on submit
*
* @return McrUndoAction
*/
private function makeNewMcrUndoAction(
RequestContext $context,
Article $article,
array $params
) : McrUndoAction {
$request = new FauxRequest( $params );
$request->setVal( 'wpSave', '' );
$context->setRequest( $request );
$outputPage = $this->createMock( 'OutputPage' );
$context->setOutput( $outputPage );
$context->setUser( $this->getTestSysop()->getUser() );
return new class( $article, $context ) extends McrUndoAction {
public function show() {
// Instead of trying to actually display anything, just initialize the class.
$this->checkCanExecute( $this->getUser() );
}
};
}
/**
* Convenience function for setting up a test page and filling it with edits.
* @param string[] $revisions
*
* @return array
*/
private function setUpPageForTesting( array $revisions ) : array {
$this->getExistingTestPage( self::PAGE_NAME );
$revisionIds = [];
foreach ( $revisions as $revisionContent ) {
$revisionIds[] = $this->editPage( self::PAGE_NAME, $revisionContent )
->value['revision-record']->getId();
}
$revisionIds['false'] = false;
return $revisionIds;
}
/**
* @param string $newContent
* @param array $revisionIds
* @param bool $isExactRevert
* @param int|string $oldestRevertedRevIndex
* @param int|string $newestRevertedRevIndex
* @param int|string $originalRevIndex
*/
private function setPageSaveCompleteHook(
string $newContent,
array $revisionIds,
bool $isExactRevert,
$oldestRevertedRevIndex,
$newestRevertedRevIndex,
$originalRevIndex
) {
// set up a temporary hook with asserts
$this->setTemporaryHook(
'PageSaveComplete',
function (
WikiPage $wikiPage,
User $user,
string $summary,
int $flags,
RevisionStoreRecord $revisionRecord,
EditResult $editResult
) use (
$newContent,
$revisionIds,
$isExactRevert,
$oldestRevertedRevIndex,
$newestRevertedRevIndex,
$originalRevIndex
) {
$this->assertTrue(
$editResult->isRevert(),
'EditResult::isRevert()'
);
$this->assertSame(
EditResult::REVERT_UNDO,
$editResult->getRevertMethod(),
'EditResult::getRevertMethod()'
);
$this->assertArrayEquals( [ 'mw-undo' ],
$editResult->getRevertTags(),
false,
false,
'EditResult::getRevertTags()'
);
$this->assertSame(
$isExactRevert,
$editResult->isExactRevert(),
'EditResult::isExactRevert()'
);
$this->assertSame(
$revisionIds[$oldestRevertedRevIndex],
$editResult->getOldestRevertedRevisionId(),
'EditResult::getOldestRevertedRevisionId()'
);
$this->assertSame(
$revisionIds[$newestRevertedRevIndex],
$editResult->getNewestRevertedRevisionId(),
'EditResult::getNewestRevertedRevisionId()'
);
$this->assertSame(
$revisionIds[$originalRevIndex],
$editResult->getOriginalRevisionId(),
'EditResult::getOriginalRevisionId()'
);
$mainContent = $revisionRecord->getContent( SlotRecord::MAIN );
/** @var WikitextContent $mainContent */
$this->assertSame(
$newContent,
$mainContent->getText(),
'RevisionRecord::getContent()'
);
}
);
}
/**
* Provides test cases for well-formed undos.
*
* @return array[]
*/
public function provideUndos() {
return [
'undoing a single revision' => [
[ '1', '2' ],
'1',
0,
1,
true,
1,
1,
0
],
'undoing multiple revisions' => [
[ '1', '2', '3', '4' ],
'1',
0,
3,
true,
1,
3,
0
],
'undoing an intermittent revision' => [
[
"line 1\n\nline 2\n\nline3",
"line 1\n\nvandalism\n\nline3",
"line 1\n\nvandalism\n\nline3 more content"
],
"line 1\n\nline 2\n\nline3 more content",
0,
1,
false,
1,
1,
'false'
],
'undoing multiple intermittent revisions' => [
[
"line 1\n\nline 2\n\nline3",
"line 1\n\nvandalism\n\nline3",
"line 1\n\nmore vandalism\n\nline3",
"line 1\n\nmore vandalism\n\nline3 content"
],
"line 1\n\nline 2\n\nline3 content",
0,
2,
false,
1,
2,
'false'
]
];
}
/**
* Provides test cases of undos with incomplete parameters.
* This should be handled well by EditPage and WikiPage.
* McrUndoAction just refuses to do anything.
*
* @return array[]
*/
public function provideIncompleteUndos() {
return [
'undoing a revision without undoafter param' => [
[ '1', '2' ],
'1',
'false',
1,
true,
1,
1,
0
],
'undoing an intermittent revision without undoafter param' => [
[
"line 1\n\nline 2\n\nline3",
"line 1\n\nvandalism\n\nline3",
"line 1\n\nvandalism\n\nline3 more content"
],
"line 1\n\nline 2\n\nline3 more content",
'false',
1,
false,
1,
1,
'false'
]
];
}
/**
* Test how McrUndoAction cooperates with the PageUpdater by looking at values provided
* by the PageSaveComplete hook.
*
* @dataProvider provideUndos
*
* @param string[] $revisions
* @param string $newContent
* @param int|string $undoafterIndex
* @param int|string $undoIndex
* @param bool $isExactRevert
* @param int|string $oldestRevertedRevIndex
* @param int|string $newestRevertedRevIndex
* @param int|string $originalRevIndex
*/
public function testMcrUndoAction(
array $revisions,
string $newContent,
$undoafterIndex,
$undoIndex,
bool $isExactRevert,
$oldestRevertedRevIndex,
$newestRevertedRevIndex,
$originalRevIndex
) {
$context = RequestContext::getMain();
$revisionIds = $this->setUpPageForTesting( $revisions );
$article = Article::newFromTitle( Title::newFromText( self::PAGE_NAME ), $context );
$mcrUndoAction = $this->makeNewMcrUndoAction(
$context,
$article,
[
'undoafter' => $revisionIds[$undoafterIndex],
'undo' => $revisionIds[$undoIndex]
]
);
// This should initialize the action properly.
$mcrUndoAction->show();
// Set the hook and submit the request
$this->setPageSaveCompleteHook(
$newContent,
$revisionIds,
$isExactRevert,
$oldestRevertedRevIndex,
$newestRevertedRevIndex,
$originalRevIndex
);
$mcrUndoAction->onSubmit( [] );
}
/**
* Test how WikiPage cooperates with the PageUpdater by looking at values
* provided by the PageSaveComplete hook.
*
* @dataProvider provideUndos
* @dataProvider provideIncompleteUndos
*
* @param string[] $revisions
* @param string $newContent
* @param int|string $undoafterIndex
* @param int|string $undoIndex
* @param bool $isExactRevert
* @param int|string $oldestRevertedRevIndex
* @param int|string $newestRevertedRevIndex
* @param int|string $originalRevIndex
*/
public function testWikiPage(
array $revisions,
string $newContent,
$undoafterIndex,
$undoIndex,
bool $isExactRevert,
$oldestRevertedRevIndex,
$newestRevertedRevIndex,
$originalRevIndex
) {
$revisionIds = $this->setUpPageForTesting( $revisions );
// Set the hook with asserts
$this->setPageSaveCompleteHook(
$newContent,
$revisionIds,
$isExactRevert,
$oldestRevertedRevIndex,
$newestRevertedRevIndex,
$originalRevIndex
);
$wikiPage = new WikiPage( Title::newFromText( self::PAGE_NAME ) );
$wikiPage->doEditContent(
new WikitextContent( $newContent ),
'',
0,
$revisionIds[$undoafterIndex],
$this->getTestSysop()->getUser(),
null,
[],
$revisionIds[$undoIndex]
);
}
/**
* Test how EditPage and WikiPage work together and with the PageUpdater by looking
* at values provided by the PageSaveComplete hook.
*
* @dataProvider provideUndos
* @dataProvider provideIncompleteUndos
*
* @param string[] $revisions
* @param string $newContent
* @param int|string $undoafterIndex
* @param int|string $undoIndex
* @param bool $isExactRevert
* @param int|string $oldestRevertedRevIndex
* @param int|string $newestRevertedRevIndex
* @param int|string $originalRevIndex
*/
public function testEditPage(
array $revisions,
string $newContent,
$undoafterIndex,
$undoIndex,
bool $isExactRevert,
$oldestRevertedRevIndex,
$newestRevertedRevIndex,
$originalRevIndex
) {
$revisionIds = $this->setUpPageForTesting( $revisions );
$context = RequestContext::getMain();
$article = Article::newFromTitle( Title::newFromText( self::PAGE_NAME ), $context );
// Set the hook with asserts
$this->setPageSaveCompleteHook(
$newContent,
$revisionIds,
$isExactRevert,
$oldestRevertedRevIndex,
$newestRevertedRevIndex,
$originalRevIndex
);
$request = new FauxRequest(
[
// We kind of let EditPage cheat here by providing the content of the page
// after the undo, but automatic conflict resolution is not the point of
// this test anyway.
'wpTextbox1' => $newContent,
'wpEditToken' => $this->getTestSysop()->getUser()->getEditToken(),
// These two parameters are the important ones here
'wpUndidRevision' => $revisionIds[$undoIndex],
'wpUndoAfter' => $revisionIds[$undoafterIndex],
'wpStarttime' => wfTimestampNow(),
'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
'model' => CONTENT_MODEL_WIKITEXT,
'format' => CONTENT_FORMAT_WIKITEXT,
],
true
);
$editPage = new EditPage( $article );
$editPage->importFormData( $request );
$editPage->internalAttemptSave( $result, false );
}
/**
* Test the case where the user undoes some edits, but applies additional changes before
* saving. EditPage should detect that and not mark such an edit as a revert.
*/
public function testDirtyUndo() {
$revisionIds = $this->setUpPageForTesting( [
"line 1\n\nline 2\n\nline3",
"line 1\n\nvandalism\n\nline3",
"line 1\n\nvandalism\n\nline3 more content"
] );
$context = RequestContext::getMain();
$article = Article::newFromTitle( Title::newFromText( self::PAGE_NAME ), $context );
// set up a temporary hook with asserts
$this->setTemporaryHook(
'PageSaveComplete',
function (
WikiPage $wikiPage,
User $user,
string $summary,
int $flags,
RevisionStoreRecord $revisionRecord,
EditResult $editResult
) {
// Just ensuring that the edit was not marked as a revert should be enough
$this->assertFalse(
$editResult->isRevert(),
'EditResult::isRevert()'
);
}
);
$request = new FauxRequest(
[
// We emulate the user applying additional changes on top of the undo.
'wpTextbox1' => "line 1\n\nline 2\n\nline3 more content\n\neven more",
'wpEditToken' => $this->getTestSysop()->getUser()->getEditToken(),
'wpUndidRevision' => $revisionIds[1],
'wpUndoAfter' => $revisionIds[0],
'wpStarttime' => wfTimestampNow(),
'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
'model' => CONTENT_MODEL_WIKITEXT,
'format' => CONTENT_FORMAT_WIKITEXT,
],
true
);
$editPage = new EditPage( $article );
$editPage->importFormData( $request );
$editPage->internalAttemptSave( $result, false );
}
}