diff options
Diffstat (limited to 'AbuseFilter/tests/phpunit/AbuseFilterConsequencesTest.php')
-rw-r--r-- | AbuseFilter/tests/phpunit/AbuseFilterConsequencesTest.php | 2080 |
1 files changed, 1654 insertions, 426 deletions
diff --git a/AbuseFilter/tests/phpunit/AbuseFilterConsequencesTest.php b/AbuseFilter/tests/phpunit/AbuseFilterConsequencesTest.php index 4be865b5..da794f1a 100644 --- a/AbuseFilter/tests/phpunit/AbuseFilterConsequencesTest.php +++ b/AbuseFilter/tests/phpunit/AbuseFilterConsequencesTest.php @@ -1,4 +1,10 @@ <?php + +use MediaWiki\Block\DatabaseBlock; +use MediaWiki\MediaWikiServices; +use MediaWiki\Storage\PageEditStash; +use PHPUnit\Framework\MockObject\MockObject; + /** * Complete tests where filters are saved, actions are executed and the right * consequences are expected to be taken @@ -28,18 +34,32 @@ * @group AbuseFilter * @group AbuseFilterConsequences * @group Database + * @group Large * * @covers AbuseFilter + * @covers AbuseFilterRunner * @covers AbuseFilterHooks + * @covers \MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGenerator + * @covers \MediaWiki\Extension\AbuseFilter\VariableGenerator\RunVariableGenerator + * @covers AbuseFilterPreAuthenticationProvider * @covers AbuseFilterParser - * @covers AFPData - * @covers AbuseFilterTokenizer - * @covers AFPToken - * @covers AbuseFilterVariableHolder - * @covers AFComputedVariable */ class AbuseFilterConsequencesTest extends MediaWikiTestCase { - protected static $mUser; + /** + * @var User The user performing actions + */ + private $user; + /** To be used as fake timestamp in several tests */ + private const MAGIC_TIMESTAMP = 2051222400; + /** Prefix for tables to emulate an external DB */ + public const DB_EXTERNAL_PREFIX = 'external_'; + /** Tables to create in the external DB */ + public static $externalTables = [ + 'abuse_filter', + 'abuse_filter_action', + 'abuse_filter_log', + 'text', + ]; /** * @var array This tables will be deleted in parent::tearDown @@ -49,32 +69,20 @@ class AbuseFilterConsequencesTest extends MediaWikiTestCase { 'abuse_filter_action', 'abuse_filter_history', 'abuse_filter_log', - 'page' - ]; - - // Properties of the filter rows that we're not interested in changing. - // Write them once to save space - protected static $defaultRowSection = [ - 'af_user_text' => 'FilterTester', - 'af_user' => 0, - 'af_timestamp' => '20180707105743', - 'af_group' => 'default', - 'af_hit_count' => 0, + 'page', + 'ipblocks', + 'logging', + 'change_tag', + 'user', + 'text' ]; + // phpcs:disable Generic.Files.LineLength // Filters that may be created, their key is the ID. protected static $filters = [ 1 => [ - 'af_id' => 1, 'af_pattern' => 'added_lines irlike "foo"', - 'af_enabled' => 1, - 'af_comments' => 'Comments', 'af_public_comments' => 'Mock filter for edit', - 'af_hidden' => 0, - 'af_throttled' => 0, - 'af_deleted' => 0, - 'af_actions' => 'warn,tag', - 'af_global' => 0, 'actions' => [ 'warn' => [ 'abusefilter-my-warning' @@ -85,16 +93,9 @@ class AbuseFilterConsequencesTest extends MediaWikiTestCase { ] ], 2 => [ - 'af_id' => 2, 'af_pattern' => 'action = "move" & moved_to_title contains "test" & moved_to_title === moved_to_text', - 'af_enabled' => 1, - 'af_comments' => 'No comment', 'af_public_comments' => 'Mock filter for move', 'af_hidden' => 1, - 'af_throttled' => 0, - 'af_deleted' => 0, - 'af_actions' => 'disallow,block', - 'af_global' => 0, 'actions' => [ 'disallow' => [], 'block' => [ @@ -105,44 +106,22 @@ class AbuseFilterConsequencesTest extends MediaWikiTestCase { ] ], 3 => [ - 'af_id' => 3, 'af_pattern' => 'action = "delete" & "test" in lcase(page_prefixedtitle) & page_prefixedtitle === article_prefixedtext', - 'af_enabled' => 1, - 'af_comments' => '', 'af_public_comments' => 'Mock filter for delete', - 'af_hidden' => 0, - 'af_throttled' => 0, - 'af_deleted' => 0, - 'af_actions' => 'degroup', - 'af_global' => 0, + 'af_global' => 1, 'actions' => [ 'degroup' => [] ] ], 4 => [ - 'af_id' => 4, 'af_pattern' => 'action contains "createaccount" & accountname rlike "user" & page_title === article_text', - 'af_enabled' => 1, - 'af_comments' => '1', 'af_public_comments' => 'Mock filter for createaccount', 'af_hidden' => 1, - 'af_throttled' => 0, - 'af_deleted' => 0, - 'af_actions' => '', - 'af_global' => 0, 'actions' => [] ], 5 => [ - 'af_id' => 5, 'af_pattern' => 'user_name == "FilteredUser"', - 'af_enabled' => 1, - 'af_comments' => '', 'af_public_comments' => 'Mock filter', - 'af_hidden' => 0, - 'af_throttled' => 0, - 'af_deleted' => 0, - 'af_actions' => 'tag', - 'af_global' => 0, 'actions' => [ 'tag' => [ 'firstTag', @@ -151,46 +130,26 @@ class AbuseFilterConsequencesTest extends MediaWikiTestCase { ] ], 6 => [ - 'af_id' => 6, 'af_pattern' => 'edit_delta === 7', - 'af_enabled' => 1, - 'af_comments' => '', 'af_public_comments' => 'Mock filter with edit_delta', 'af_hidden' => 1, - 'af_throttled' => 0, - 'af_deleted' => 0, - 'af_actions' => 'disallow', - 'af_global' => 0, + 'af_global' => 1, 'actions' => [ - 'disallow' => [] + 'disallow' => [ + 'abusefilter-disallowed-really' + ] ] ], 7 => [ - 'af_id' => 7, 'af_pattern' => 'timestamp === int(timestamp)', - 'af_enabled' => 1, - 'af_comments' => '', 'af_public_comments' => 'Mock filter with timestamp', - 'af_hidden' => 0, - 'af_throttled' => 0, - 'af_deleted' => 0, - 'af_actions' => 'degroup', - 'af_global' => 0, 'actions' => [ 'degroup' => [] ] ], 8 => [ - 'af_id' => 8, 'af_pattern' => 'added_lines_pst irlike "\\[\\[Link\\|Link\\]\\]"', - 'af_enabled' => 1, - 'af_comments' => '', 'af_public_comments' => 'Mock filter with pst', - 'af_hidden' => 0, - 'af_hit_count' => 0, - 'af_deleted' => 0, - 'af_actions' => 'disallow,block', - 'af_global' => 0, 'actions' => [ 'disallow' => [], 'block' => [ @@ -201,16 +160,9 @@ class AbuseFilterConsequencesTest extends MediaWikiTestCase { ] ], 9 => [ - 'af_id' => 9, 'af_pattern' => 'new_size > old_size', - 'af_enabled' => 1, - 'af_comments' => '', 'af_public_comments' => 'Mock filter with size', 'af_hidden' => 1, - 'af_throttled' => 0, - 'af_deleted' => 0, - 'af_actions' => 'disallow,block', - 'af_global' => 0, 'actions' => [ 'disallow' => [], 'block' => [ @@ -221,16 +173,10 @@ class AbuseFilterConsequencesTest extends MediaWikiTestCase { ] ], 10 => [ - 'af_id' => 10, 'af_pattern' => '1 == 1', - 'af_enabled' => 1, - 'af_comments' => '', 'af_public_comments' => 'Mock throttled filter', 'af_hidden' => 1, 'af_throttled' => 1, - 'af_deleted' => 0, - 'af_actions' => 'tag,block', - 'af_global' => 0, 'actions' => [ 'tag' => [ 'testTag' @@ -243,36 +189,20 @@ class AbuseFilterConsequencesTest extends MediaWikiTestCase { ] ], 11 => [ - 'af_id' => 11, 'af_pattern' => '1 == 1', - 'af_enabled' => 1, - 'af_comments' => '', - 'af_public_comments' => 'Mock filter which throttles', - 'af_hidden' => 0, - 'af_throttled' => 0, - 'af_deleted' => 0, - 'af_actions' => 'throttle,disallow', - 'af_global' => 0, + 'af_public_comments' => 'Catch-all filter which throttles', 'actions' => [ 'throttle' => [ 11, '1,3600', - 'user' + 'site' ], 'disallow' => [] ] ], 12 => [ - 'af_id' => 12, 'af_pattern' => 'page_title == user_name & user_name === page_title', - 'af_enabled' => 1, - 'af_comments' => '', 'af_public_comments' => 'Mock filter for userpage', - 'af_hidden' => 0, - 'af_throttled' => 0, - 'af_deleted' => 0, - 'af_actions' => 'disallow,block,degroup', - 'af_global' => 0, 'actions' => [ 'disallow' => [], 'block' => [ @@ -284,16 +214,9 @@ class AbuseFilterConsequencesTest extends MediaWikiTestCase { ] ], 13 => [ - 'af_id' => 13, 'af_pattern' => '2 == 2', - 'af_enabled' => 1, - 'af_comments' => '', 'af_public_comments' => 'Another throttled mock filter', - 'af_hidden' => 0, 'af_throttled' => 1, - 'af_deleted' => 0, - 'af_actions' => 'block,degroup', - 'af_global' => 0, 'actions' => [ 'block' => [ 'blocktalk', @@ -304,38 +227,126 @@ class AbuseFilterConsequencesTest extends MediaWikiTestCase { ] ], 14 => [ - 'af_id' => 14, 'af_pattern' => '5/int(article_text) == 3', - 'af_enabled' => 1, - 'af_comments' => '', 'af_public_comments' => 'Filter with a possible division by zero', - 'af_hidden' => 0, - 'af_throttled' => 0, - 'af_deleted' => 0, - 'af_actions' => 'disallow', - 'af_global' => 0, 'actions' => [ 'disallow' => [] ] + ], + 15 => [ + 'af_pattern' => 'action contains "createaccount"', + 'af_public_comments' => 'Catch-all for account creations', + 'af_hidden' => 1, + 'actions' => [ + 'disallow' => [] + ] + ], + 16 => [ + 'af_pattern' => 'random := "adruhaoerihouhae"; added_lines contains random | ' . + 'edit_diff_pst contains random | new_pst contains random | new_html contains random |' . + '1=1 | /*Superfluous condition to set a lazyLoader but not compute*/all_links contains random', + 'af_public_comments' => 'Filter computing several non-lazy variables', + 'actions' => [ + 'disallow' => [] + ] + ], + 17 => [ + 'af_pattern' => 'timestamp = "' . self::MAGIC_TIMESTAMP . '" | 3 = 2 | 1 = 4 | 5 = 7 | 6 = 3', + 'af_comments' => 'This will normally consume 5 conditions, unless the timestamp is set to' . + 'the magic value of self::MAGIC_TIMESTAMP.', + 'af_public_comments' => 'Test with variable conditions', + 'actions' => [ + 'tag' => [ + 'testTagProfiling' + ] + ] + ], + 18 => [ + 'af_pattern' => '1 == 1', + 'af_public_comments' => 'Global filter', + 'af_global' => 1, + 'actions' => [ + 'warn' => [ + 'abusefilter-warning' + ], + 'disallow' => [] + ] + ], + 19 => [ + 'af_pattern' => 'user_name === "FilteredUser"', + 'af_public_comments' => 'Another global filter', + 'af_global' => 1, + 'actions' => [ + 'tag' => [ + 'globalTag' + ] + ] + ], + 20 => [ + 'af_pattern' => 'page_title === "Cellar door"', + 'af_public_comments' => 'Yet another global filter', + 'af_global' => 1, + 'actions' => [ + 'disallow' => [], + ] + ], + 21 => [ + 'af_pattern' => '1==1', + 'af_public_comments' => 'Dangerous filter', + 'actions' => [ + 'blockautopromote' => [] + ] + ], + 22 => [ + 'af_pattern' => 'action contains "upload" & "Block me" in added_lines & file_size > 0 & ' . + 'file_mime contains "/" & file_width + file_height > 0 & summary !== ""', + 'af_public_comments' => 'Filter for uploads', + 'actions' => [ + 'warn' => [ + 'abusefilter-random-upload' + ], + 'blockautopromote' => [] + ] ] ]; + // phpcs:enable Generic.Files.LineLength + + /** + * Add tables for global filters to the list of used tables + * + * @inheritDoc + */ + public function __construct( $name = null, array $data = [], $dataName = '' ) { + $prefixedTables = array_map( + function ( $table ) { + return self::DB_EXTERNAL_PREFIX . $table; + }, + self::$externalTables + ); + $this->tablesUsed = array_merge( $this->tablesUsed, $prefixedTables ); + $this->user = User::newFromName( 'FilteredUser' ); + parent::__construct( $name, $data, $dataName ); + } /** - * @see MediaWikiTestCase::setUp + * @inheritDoc */ - protected function setUp() { + protected function setUp() : void { parent::setUp(); - $user = User::newFromName( 'FilteredUser' ); - $user->addToDatabase(); - $user->addGroup( 'sysop' ); - if ( $user->isBlocked() ) { - $block = Block::newFromTarget( $user ); + // Ensure that our user is not blocked and is a sysop (matched filters could block or + // degroup the user) + $this->user->addToDatabase(); + $this->user->addGroup( 'sysop' ); + $block = DatabaseBlock::newFromTarget( $this->user ); + if ( $block ) { $block->delete(); } - self::$mUser = $user; + // Make sure that the config we're using is the one we're expecting $this->setMwGlobals( [ - 'wgUser' => $user, + 'wgUser' => $this->user, + // Exclude noisy creation log + 'wgPageCreationLog' => false, 'wgAbuseFilterActions' => [ 'throttle' => true, 'warn' => true, @@ -346,224 +357,368 @@ class AbuseFilterConsequencesTest extends MediaWikiTestCase { 'degroup' => true, 'tag' => true ], - 'wgAbuseFilterRuntimeProfile' => true, - 'wgAbuseFilterProfile' => true + 'wgAbuseFilterCentralDB' => $this->db->getDBname() . '-' . $this->dbPrefix() . + self::DB_EXTERNAL_PREFIX, + 'wgAbuseFilterIsCentral' => false, + 'wgMainCacheType' => 'hash', ] ); } /** - * Performs an edit. Freely adapted from EditPageTest::assertEdit + * @inheritDoc + */ + protected function tearDown() : void { + // Paranoia: ensure no fake timestamp leftover + MWTimestamp::setFakeTime( false ); + // Clear any upload + $_FILES = []; + parent::tearDown(); + } + + /** + * Creates new filters with the given ids, referred to self::$filters + * + * @param int[] $ids IDs of the filters to create + * @param bool $external Whether to create filters in the external table + */ + private function createFilters( $ids, $external = false ) { + global $wgAbuseFilterActions; + $tablePrefix = $external ? self::DB_EXTERNAL_PREFIX : ''; + $defaultRowSection = [ + 'af_user_text' => 'FilterTester', + 'af_user' => 0, + 'af_timestamp' => $this->db->timestamp(), + 'af_group' => 'default', + 'af_comments' => '', + 'af_hit_count' => 0, + 'af_enabled' => 1, + 'af_hidden' => 0, + 'af_throttled' => 0, + 'af_deleted' => 0, + 'af_global' => 0 + ]; + + foreach ( $ids as $id ) { + $filter = self::$filters[$id] + $defaultRowSection; + $actions = $filter['actions']; + unset( $filter['actions'] ); + $filter[ 'af_actions' ] = implode( ',', array_keys( $actions ) ); + $filter[ 'af_id' ] = $id; + + $this->db->insert( + "{$tablePrefix}abuse_filter", + $filter, + __METHOD__ + ); + + $actionsRows = []; + foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) { + if ( isset( $actions[$action] ) ) { + $parameters = $actions[$action]; + + $thisRow = [ + 'afa_filter' => $id, + 'afa_consequence' => $action, + 'afa_parameters' => implode( "\n", $parameters ) + ]; + $actionsRows[] = $thisRow; + } + } + + $this->db->insert( + "{$tablePrefix}abuse_filter_action", + $actionsRows, + __METHOD__ + ); + } + } + + /** + * Stash the edit. * * @param Title $title Title of the page to edit + * @param string $text The new content of the page + * @param string $summary The summary of the edit + * @return string The status of the operation, as returned by the API. + */ + private function stashEdit( $title, $text, $summary ) { + $editStash = new PageEditStash( + new HashBagOStuff( [] ), + MediaWikiServices::getInstance()->getDBLoadBalancer(), + new Psr\Log\NullLogger(), + new NullStatsdDataFactory(), + MediaWikiServices::getInstance()->getHookContainer(), + PageEditStash::INITIATOR_USER + ); + return $editStash->parseAndCache( + WikiPage::factory( $title ), + new WikitextContent( $text ), + $this->user, + $summary + ); + } + + /** + * @param Title $title Title of the page to edit * @param string $oldText Old content of the page * @param string $newText The new content of the page * @param string $summary The summary of the edit + * @param bool|null $fromStash Whether to stash the edit. Null means no stashing, false means + * stash the edit but don't reuse it for saving, true means stash and reuse. * @return Status */ - private static function doEdit( $title, $oldText, $newText, $summary ) { + private function doEdit( Title $title, $oldText, $newText, $summary, $fromStash = null ) { $page = WikiPage::factory( $title ); - $content = ContentHandler::makeContent( $oldText, $title ); - $page->doEditContent( $content, 'Creating the page for testing AbuseFilter.' ); - - $params = [ - 'wpTextbox1' => $newText, - 'wpSummary' => $summary, - 'wpEditToken' => self::$mUser->getEditToken(), - 'wpEdittime' => $page->getTimestamp(), - 'wpStarttime' => wfTimestampNow(), - 'wpUnicodeCheck' => EditPage::UNICODE_CHECK, - 'wpSectionTitle' => '', - 'wpMinorEdit' => false, - 'wpWatchthis' => false + if ( !$page->exists() ) { + $status = $this->editPage( + $title->getText(), + $oldText, + __METHOD__ . ' page creation', + $title->getNamespace() + ); + if ( !$status->isGood() ) { + throw new Exception( "Could not create test page. $status" ); + } + $title->resetArticleID( -1 ); + } + + if ( $fromStash !== null ) { + // If we want to save from stash, submit the same text + $stashText = $newText; + if ( $fromStash === false ) { + // Otherwise, stash some random text which won't match the actual edit + $stashText = md5( uniqid( rand(), true ) ); + } + $stashResult = $this->stashEdit( $title, $stashText, $summary ); + if ( $stashResult !== PageEditStash::ERROR_NONE ) { + throw new MWException( "The edit cannot be stashed, got the following error: $stashResult" ); + } + } + + $content = ContentHandler::makeContent( $newText, $title ); + $status = Status::newGood(); + $context = RequestContext::getMain(); + $context->setTitle( $title ); + $context->setUser( $this->user ); + + AbuseFilterHooks::onEditFilterMergedContent( $context, $content, $status, $summary, + $this->user, false ); + + if ( $status->isGood() ) { + // Edit the page in case the test will expect for it to exist + $this->editPage( + $title->getText(), + $newText, + $summary, + $title->getNamespace(), + $this->user + ); + } + + return $status; + } + + /** + * Upload a file. This is based on ApiUploadTestCase::fakeUploadFile + * @param string $fileName + * @param string $pageText + * @param string $summary + * @return Status + */ + private function doUpload( string $fileName, string $pageText, string $summary ) : Status { + $imgGen = new RandomImageGenerator(); + // Use SVG, since the ImageGenerator doesn't need anything special to create it + $format = 'svg'; + $mime = 'image/svg+xml'; + $filePath = $imgGen->writeImages( 1, $format, $this->getNewTempDirectory() )[0]; + clearstatcache(); + $_FILES[ 'wpUploadFile' ] = [ + 'name' => $fileName, + 'type' => $mime, + 'tmp_name' => $filePath, + 'error' => UPLOAD_ERR_OK, + 'size' => filesize( $filePath ), ]; - $req = new FauxRequest( $params, true ); - - $article = new Article( $title ); - $article->getContext()->setTitle( $title ); - $article->getContext()->setUser( self::$mUser ); - $ep = new EditPage( $article ); - $ep->setContextTitle( $title ); - $ep->importFormData( $req ); - return $ep->internalAttemptSave( $result ); + $request = new FauxRequest( [ + 'wpDestFile' => $fileName + ] ); + $ub = UploadBase::createFromRequest( $request ); + $ub->verifyUpload(); + return $ub->performUpload( $summary, $pageText, false, $this->user ); } /** * Executes an action to filter * * @param array $params Parameters of the action - * @param array $options Further options - * @return Status|Status[] + * @return Status */ - private static function doAction( $params, $options ) { - $type = array_shift( $params ); - $target = array_shift( $params ); - $target = Title::newFromText( $target ); + private function doAction( $params ) { + $target = Title::newFromText( $params['target'] ); // Make sure that previous blocks don't affect the test - self::$mUser->clearInstanceCache(); + $this->user->clearInstanceCache(); - switch ( $type ) { + switch ( $params['action'] ) { case 'edit': - if ( in_array( 'makeGoodEditFirst', $options ) ) { - $firstStatus = self::doEdit( - $target, $params['oldText'], $params['firstNewText'], $params['summary'] - ); - $secondStatus = self::doEdit( - $target, $params['firstNewText'], $params['secondNewText'], $params['summary'] - ); - $status = [ $firstStatus, $secondStatus ]; - } else { - $status = self::doEdit( $target, $params['oldText'], $params['newText'], $params['summary'] ); - } + $status = $this->doEdit( $target, $params['oldText'], $params['newText'], $params['summary'] ); + break; + case 'stashedit': + $stashStatus = $params['stashType'] === 'hit'; + $status = $this->doEdit( + $target, + $params['oldText'], + $params['newText'], + $params['summary'], + $stashStatus + ); break; case 'move': - $move = new MovePage( $target, Title::newFromText( $params['newTitle'] ) ); - $status = $move->checkPermissions( self::$mUser, 'AbuseFilter move test' ); + // Ensure that the page exists + $this->getExistingTestPage( $target ); + $newTitle = isset( $params['newTitle'] ) + ? Title::newFromText( $params['newTitle'] ) + : $this->getNonExistingTestPage()->getTitle(); + /** @var MockObject|MovePage $mp */ + $mp = $this->getMockBuilder( MovePage::class ) + ->setMethods( [ 'isValidMove' ] ) + ->setConstructorArgs( [ $target, $newTitle ] ) + ->getMock(); + $mp->expects( $this->any() ) + ->method( 'isValidMove' ) + ->willReturn( Status::newGood() ); + $status = $mp->move( $this->user, 'AbuseFilter move test', false ); break; case 'delete': - $page = WikiPage::factory( $target ); - $content = ContentHandler::makeContent( 'Page to be deleted in AbuseFilter test', $target ); - $page->doEditContent( $content, 'Creating the page for testing deletion AbuseFilter.' ); - $status = $page->doDeleteArticleReal( 'Testing deletion in AbuseFilter' ); + $page = $this->getExistingTestPage( $target ); + $status = $page->doDeleteArticleReal( + 'Testing deletion in AbuseFilter', + $this->user + ); break; case 'createaccount': $user = User::newFromName( $params['username'] ); + // A creatable username must exist to be passed to $logEntry->setPerformer(), + // so create the account. + $user->addToDatabase(); + $provider = new AbuseFilterPreAuthenticationProvider(); $status = $provider->testForAccountCreation( $user, $user, [] ); + + $logEntry = new ManualLogEntry( 'newusers', 'create' ); + $logEntry->setPerformer( $user ); + $logEntry->setTarget( $user->getUserPage() ); + $logid = $logEntry->insert(); + $logEntry->publish( $logid ); + break; + case 'upload': + $status = $this->doUpload( + $params['target'], + $params['newText'] ?? 'AbuseFilter test upload', + $params['summary'] ?? 'Test' + ); break; + default: + throw new UnexpectedValueException( 'Unrecognized action ' . $params['action'] ); } // Clear cache since we'll need to retrieve some fresh data about the user // like blocks and groups later when checking expected values - self::$mUser->clearInstanceCache(); + $this->user->clearInstanceCache(); return $status; } /** - * Creates new filters with the given ids, referred to self::$filters - * - * @param int[] $ids IDs of the filters to create + * @param array[] $actionsParams Arrays of parameters for every action + * @return Status[] */ - private static function createFilters( $ids ) { - global $wgAbuseFilterActions; - $dbw = wfGetDB( DB_MASTER ); - - foreach ( $ids as $id ) { - $filter = array_merge( self::$filters[$id], self::$defaultRowSection ); - $actions = $filter['actions']; - unset( $filter['actions'] ); - - $dbw->replace( - 'abuse_filter', - [ 'af_id' ], - $filter, - __METHOD__ - ); - - $actionRows = []; - foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) { - if ( isset( $actions[$action] ) ) { - $parameters = $actions[$action]; - - $thisRow = [ - 'afa_filter' => $id, - 'afa_consequence' => $action, - 'afa_parameters' => implode( "\n", $parameters ) - ]; - $actionsRows[] = $thisRow; - } - } - - $dbw->replace( - 'abuse_filter_action', - [ 'afa_filter' ], - $actionsRows, - __METHOD__ - ); + private function doActions( $actionsParams ) { + $ret = []; + foreach ( $actionsParams as $params ) { + $ret[] = $this->doAction( $params ); } + return $ret; } /** - * Creates new filters, execute an action and check the consequences + * Helper function to retrieve change tags applied to an edit or log entry * - * @param string $testDescription A short description of the test, used for error reporting - * @param int[] $createIds IDs of the filters to create - * @param array $actionParams Details of the action we need to execute to trigger filters - * @param array $consequences The consequences we're expecting - * @param array $options Further options for the test - * @covers AbuseFilter - * @dataProvider provideFilters + * @param array $actionParams As given by the data provider + * @return string[] The applied tags */ - public function testFilterConsequences( - $testDescription, - $createIds, - $actionParams, - $consequences, - $options - ) { - global $wgLang; - self::createFilters( $createIds ); - - if ( in_array( 'makeGoodEditFirst', $options ) ) { - $this->setMwGlobals( [ - // Necessary to test throttle - 'wgMainCacheType' => CACHE_ANYTHING - ] ); - } - if ( in_array( 'hitCondsLimit', $options ) ) { - $this->setMwGlobals( [ - 'wgAbuseFilterConditionLimit' => 0 - ] ); + private function getActionTags( $actionParams ) { + if ( $actionParams['action'] === 'edit' || $actionParams['action'] === 'stashedit' ) { + $page = WikiPage::factory( Title::newFromText( $actionParams['target'] ) ); + return ChangeTags::getTags( $this->db, null, $page->getLatest() ); } - if ( in_array( 'hitTimeLimit', $options ) ) { - $this->setMwGlobals( [ - 'wgAbuseFilterSlowFilterRuntimeLimit' => 0 - ] ); - } - if ( in_array( 'hitThrottleLimit', $options ) ) { - $this->setMwGlobals( [ - 'wgAbuseFilterEmergencyDisableCount' => [ - 'default' => 0 - ] - ] ); + + $logType = $actionParams['action'] === 'createaccount' ? 'newusers' : $actionParams['action']; + $logAction = $logType === 'newusers' ? 'create' : $logType; + $title = Title::newFromText( $actionParams['target'] ); + $id = $this->db->selectField( + 'logging', + 'log_id', + [ + 'log_title' => $title->getDBkey(), + 'log_type' => $logType, + 'log_action' => $logAction + ], + __METHOD__, + [], + [ 'ORDER BY' => 'log_id DESC' ] + ); + if ( !$id ) { + $this->fail( 'Could not find the action in the logging table.' ); } + return ChangeTags::getTags( $this->db, null, null, $id ); + } - $result = self::doAction( $actionParams, $options ); + /** + * Checks that consequences are effectively taken and builds an array of expected and actual + * consequences which can be compared. + * + * @param Status $result As returned by self::doAction + * @param array $actionParams As it's given by data providers + * @param array $consequences As it's given by data providers + * @return array [ expected consequences, actual consequences ] + */ + private function checkConsequences( $result, $actionParams, $consequences ) { + global $wgAbuseFilterRestrictions; $expectedErrors = []; $testErrorMessage = false; foreach ( $consequences as $consequence => $ids ) { foreach ( $ids as $id ) { $params = self::$filters[$id]['actions'][$consequence]; - $success = true; switch ( $consequence ) { case 'warn': // Aborts the hook with the warning message as error. - $expectedErrors['warn'][] = $params[0]; + $expectedErrors['warn'][] = $params[0] ?? 'abusefilter-warning'; break; case 'disallow': - // Aborts the hook with 'abusefilter-disallowed' error. - $expectedErrors['disallow'][] = 'abusefilter-disallowed'; + // Aborts the hook with the disallow message error. + $expectedErrors['disallow'][] = $params[0] ?? 'abusefilter-disallowed'; break; case 'block': // Aborts the hook with 'abusefilter-blocked-display' error. Should block // the user with expected duration and options. - $userBlock = self::$mUser->getBlock( false ); + $userBlock = $this->user->getBlock( false ); if ( !$userBlock ) { $testErrorMessage = "User isn't blocked."; break; } - $shouldPreventTalkEdit = $params[0] === 'blocktalk'; - $edittalkCheck = $userBlock->prevents( 'editownusertalk' ) === $shouldPreventTalkEdit; + $shouldPreventTalkEdit = $params[0] === 'blocktalk'; + $edittalkCheck = $userBlock->appliesToUsertalk( $this->user->getTalkPage() ) === + $shouldPreventTalkEdit; if ( !$edittalkCheck ) { $testErrorMessage = 'The expected block option "edittalk" options does not ' . 'match the actual one.'; break; } - $expectedExpiry = SpecialBlock::parseExpiryInput( $params[2] ); + $expectedExpiry = SpecialBlock::parseExpiryInput( $params[2] ); // Get rid of non-numeric 'infinity' by setting it to 0 $actualExpiry = wfIsInfinity( $userBlock->getExpiry() ) ? 0 : $userBlock->getExpiry(); $expectedExpiry = wfIsInfinity( $expectedExpiry ) ? 0 : $expectedExpiry; @@ -575,328 +730,1401 @@ class AbuseFilterConsequencesTest extends MediaWikiTestCase { break; } - $expectedErrors['block'][] = 'abusefilter-blocked-display'; + $expectedErrors['block'][] = 'abusefilter-blocked-display'; break; case 'degroup': // Aborts the hook with 'abusefilter-degrouped' error and degroups the user. $expectedErrors['degroup'][] = 'abusefilter-degrouped'; - $groupCheck = !in_array( 'sysop', self::$mUser->getEffectiveGroups() ); + $groupCheck = !in_array( 'sysop', $this->user->getEffectiveGroups() ); if ( !$groupCheck ) { $testErrorMessage = 'The user was not degrouped.'; } break; case 'tag': - // Only add tags, to be retrieved in tag_summary table. - if ( $actionParams[1] === null ) { - // It's an account creation, so no tags. - break; - } - $title = Title::newFromText( $actionParams[1] ); - $page = WikiPage::factory( $title ); - $revId = $page->getLatest(); - $dbr = wfGetDB( DB_REPLICA ); - $appliedTags = $dbr->selectField( - 'tag_summary', - 'ts_tags', - [ 'ts_rev_id' => $revId ], - __METHOD__ - ); - $appliedTags = explode( ',', $appliedTags ); - - $tagCheck = count( array_diff( $params, $appliedTags ) ) === 0; + // Only adds tags, to be retrieved in change_tag table. + $appliedTags = $this->getActionTags( $actionParams ); + $tagCheck = count( array_diff( $params, $appliedTags ) ) === 0; if ( !$tagCheck ) { - $expectedTags = $wgLang->commaList( $params ); - $actualTags = $wgLang->commaList( $appliedTags ); + $expectedTags = implode( ', ', $params ); + $actualTags = implode( ', ', $appliedTags ); - $testErrorMessage = "Expected the edit to have the following tags: $expectedTags. " . + $testErrorMessage = "Expected the action to have the following tags: $expectedTags. " . "Got the following instead: $actualTags."; } break; case 'throttle': - // The action was executed twice and $result is an array of two Status objects. - if ( !$result[0]->isGood() ) { - // The first one should be fine - $testErrorMessage = "The first edit should have been saved, being only throttled."; - break; + throw new UnexpectedValueException( 'Use self::testThrottleConsequence to test throttling' ); + case 'blockautopromote': + // Aborts the hook with 'abusefilter-autopromote-blocked' error and prevent promotion. + $expectedErrors['blockautopromote'][] = 'abusefilter-autopromote-blocked'; + $value = AbuseFilter::getAutoPromoteBlockStatus( $this->user ); + if ( !$value ) { + $testErrorMessage = "The key for blocking autopromotion wasn't set."; } - - $result = $result[1]; break; + default: + throw new UnexpectedValueException( "Consequence not recognized: $consequence." ); } if ( $testErrorMessage ) { - $this->fail( "$testErrorMessage Test description: $testDescription" ); + $this->fail( $testErrorMessage ); } } } - if ( in_array( 'hitThrottleLimit', $options ) ) { - $dbr = wfGetDB( DB_REPLICA ); - $throttled = true; - foreach ( $createIds as $filter ) { - $curThrottle = $dbr->selectField( - 'abuse_filter', - 'af_throttled', - [ 'af_id' => $filter ], - __METHOD__ - ); - $throttled &= $curThrottle; + if ( array_intersect_key( $expectedErrors, array_filter( $wgAbuseFilterRestrictions ) ) ) { + $filteredExpected = array_intersect_key( + $expectedErrors, + array_filter( $wgAbuseFilterRestrictions ) + ); + $expected = []; + foreach ( $filteredExpected as $values ) { + $expected = array_merge( $expected, $values ); } - - if ( !$throttled ) { - $expectedThrottled = $wgLang->commaList( $createIds ); - $this->fail( 'Expected the following filters to be automatically ' . - "throttled: $expectedThrottled." ); + } else { + $expected = $expectedErrors['warn'] ?? $expectedErrors['disallow'] ?? null; + if ( !is_array( $expected ) ) { + $expected = (array)$expected; } } - // Errors have a priority order - $expected = $expectedErrors['warn'] ?? $expectedErrors['degroup'] ?? - $expectedErrors['block'] ?? $expectedErrors['disallow'] ?? null; - if ( isset( $expectedErrors['degroup'] ) && $expected === $expectedErrors['degroup'] && - isset( $expectedErrors['block'] ) ) { - // Degroup and block warning can be fired together - $expected = array_merge( $expectedErrors['degroup'], $expectedErrors['block'] ); - } elseif ( !is_array( $expected ) ) { - $expected = (array)$expected; - } - $errors = $result->getErrors(); $actual = []; foreach ( $errors as $error ) { - $msg = $error['message']; + // We don't use any of the "API" stuff in ApiMessage here, but this is the most + // convenient way to get a Message from a StatusValue error structure. + $msg = ApiMessage::create( $error )->getKey(); if ( strpos( $msg, 'abusefilter' ) !== false ) { $actual[] = $msg; } } - $expectedDisplay = $wgLang->commaList( $expected ); - $actualDisplay = $wgLang->commaList( $actual ); + sort( $expected ); + sort( $actual ); + return [ $expected, $actual ]; + } + + /** + * Creates new filters, execute an action and check the consequences + * + * @param int[] $createIds IDs of the filters to create + * @param array $actionParams Details of the action we need to execute to trigger filters + * @param array $consequences The consequences we're expecting + * @dataProvider provideFilters + */ + public function testFilterConsequences( $createIds, $actionParams, $consequences ) { + $this->createFilters( $createIds ); + $result = $this->doAction( $actionParams ); + list( $expected, $actual ) = $this->checkConsequences( $result, $actionParams, $consequences ); $this->assertEquals( $expected, $actual, - "The edit should have returned the following error messages: $expectedDisplay. " . - "Got $actualDisplay instead. Test description: $testDescription" + 'The error messages obtained by performing the action do not match.' ); } /** - * Data provider for creating and editing filters. For every test case, we pass + * Data provider for testFilterConsequences. For every test case, we pass * - an array with the IDs of the filters to be created (listed in self::$filters), * - an array with details of the action to execute in order to trigger the filters, * - an array of expected consequences of the form * [ 'consequence name' => [ IDs of the filter to take its parameters from ] ] * Such IDs may be more than one if we have a warning that is shown twice. - * - an array with further options for testing * * @return array */ public function provideFilters() { return [ - [ - 'Basic test for "edit" action.', + 'Basic test for "edit" action' => [ [ 1, 2 ], [ - 'edit', - 'Test page', + 'action' => 'edit', + 'target' => 'Test page', 'oldText' => 'Some old text for the test.', 'newText' => 'I like foo', 'summary' => 'Test AbuseFilter for edit action.' ], - [ 'warn' => [ 1 ] ], + [ 'warn' => [ 1 ] ] + ], + 'Basic test for "move" action' => [ + [ 2 ], + [ + 'action' => 'move', + 'target' => 'Test page', + 'newTitle' => 'Another test page' + ], + [ 'disallow' => [ 2 ], 'block' => [ 2 ] ] + ], + 'Basic test for "delete" action' => [ + [ 2, 3 ], + [ + 'action' => 'delete', + 'target' => 'Test page' + ], + [ 'degroup' => [ 3 ] ] + ], + 'Basic test for "createaccount", no consequences.' => [ + [ 1, 2, 3, 4 ], + [ + 'action' => 'createaccount', + 'target' => 'User:AnotherUser', + 'username' => 'AnotherUser' + ], + [] + ], + 'Basic test for "createaccount", disallowed.' => [ + [ 15 ], + [ + 'action' => 'createaccount', + 'target' => 'User:AnotherUser', + 'username' => 'AnotherUser' + ], + [ 'disallow' => [ 15 ] ] + ], + 'Check that all tags are applied' => [ + [ 5 ], + [ + 'action' => 'edit', + 'target' => 'User:FilteredUser', + 'oldText' => 'Hey.', + 'newText' => 'I am a very nice user, really!', + 'summary' => '' + ], + [ 'tag' => [ 5 ] ] + ], + [ + [ 6 ], + [ + 'action' => 'edit', + 'target' => 'Help:Help', + 'oldText' => 'Some help.', + 'newText' => 'Some help for you', + 'summary' => 'Help! I need somebody' + ], + [ 'disallow' => [ 6 ] ] + ], + 'Check that degroup and block are executed together' => [ + [ 2, 3, 7, 8 ], + [ + 'action' => 'edit', + 'target' => 'Link', + 'oldText' => 'What is a link?', + 'newText' => 'A link is something like this: [[Link|]].', + 'summary' => 'Explaining' + ], + [ 'degroup' => [ 7 ], 'block' => [ 8 ] ] + ], + 'Check that the block duration is the longer one' => [ + [ 8, 9 ], + [ + 'action' => 'edit', + 'target' => 'Whatever', + 'oldText' => 'Whatever is whatever', + 'newText' => 'Whatever is whatever, whatever it is. BTW, here is a [[Link|]]', + 'summary' => 'Whatever' + ], + [ 'disallow' => [ 8 ], 'block' => [ 8 ] ] + ], + 'Check that throttled filters only execute "safe" actions' => [ + [ 10 ], + [ + 'action' => 'edit', + 'target' => 'Buffalo', + 'oldText' => 'Buffalo', + 'newText' => 'Buffalo buffalo Buffalo buffalo buffalo buffalo Buffalo buffalo.', + 'summary' => 'Buffalo!' + ], + [ 'tag' => [ 10 ] ] + ], + 'Check that degroup and block are both executed and degroup warning is shown twice' => [ + [ 1, 3, 7, 12 ], + [ + 'action' => 'edit', + 'target' => 'User:FilteredUser', + 'oldText' => '', + 'newText' => 'A couple of lines about me...', + 'summary' => 'My user page' + ], + [ 'block' => [ 12 ], 'degroup' => [ 7, 12 ] ] + ], + 'Check that every throttled filter only executes "safe" actions' => [ + [ 10, 13 ], + [ + 'action' => 'edit', + 'target' => 'Tyger! Tyger! Burning bright', + 'oldText' => 'In the forests of the night', + 'newText' => 'What immortal hand or eye', + 'summary' => 'Could frame thy fearful symmetry?' + ], + [ 'tag' => [ 10 ] ] + ], + 'Check that runtime exceptions (division by zero) are correctly handled' => [ + [ 14 ], + [ + 'action' => 'edit', + 'target' => '0', + 'oldText' => 'Old text', + 'newText' => 'New text', + 'summary' => 'Some summary' + ], [] ], + 'Test for blockautopromote action.' => [ + [ 21 ], + [ + 'action' => 'edit', + 'target' => 'Rainbow', + 'oldText' => '', + 'newText' => '...', + 'summary' => '' + ], + [ 'blockautopromote' => [ 21 ] ], + ], + [ + [ 8, 10 ], + [ + 'action' => 'edit', + 'target' => 'Anything', + 'oldText' => 'Bar', + 'newText' => 'Foo', + 'summary' => '' + ], + [] + ], + [ + [ 7, 12 ], + [ + 'action' => 'edit', + 'target' => 'Something', + 'oldText' => 'Please allow me', + 'newText' => 'to introduce myself', + 'summary' => '' + ], + [ 'degroup' => [ 7 ] ] + ], + [ + [ 13 ], + [ + 'action' => 'edit', + 'target' => 'My page', + 'oldText' => '', + 'newText' => 'AbuseFilter will not block me', + 'summary' => '' + ], + [] + ], + 'Test upload action' => [ + [ 5 ], + [ + 'action' => 'upload', + 'target' => 'MyFile.jpg', + ], + [ 'tag' => [ 5 ] ] + ], + 'Test upload action 2' => [ + [ 22 ], + [ + 'action' => 'upload', + 'target' => 'MyFile.jpg', + 'newText' => 'Block me please!', + 'summary' => 'Asking to be blocked' + ], + [ 'warn' => [ 22 ] ] + ], + ]; + } + + /** + * Check that hitting the conditions limit stops the execution, and thus no actions are taken. + * + * @param int[] $createIds IDs of the filters to create + * @param array $actionParams Details of the action we need to execute to trigger filters + * @covers AbuseFilterParser::getCondCount + * @covers AbuseFilterParser::raiseCondCount + * @covers AbuseFilterRunner::checkAllFilters + * @dataProvider provideFiltersNoConsequences + */ + public function testCondsLimit( $createIds, $actionParams ) { + $this->createFilters( $createIds ); + $this->setMwGlobals( [ 'wgAbuseFilterConditionLimit' => 0 ] ); + $res = $this->doAction( $actionParams ); + + $this->assertTrue( + $res->isGood(), + "The action should succeed when testing the conds limit. Error: $res" + ); + $appliedTags = $this->getActionTags( $actionParams ); + $this->assertContains( + 'abusefilter-condition-limit', + $appliedTags, + "The action wasn't tagged with 'abusefilter-condition-limit' upon hitting the limit" + ); + } + + /** + * Check that hitting the time limit is logged + * + * @param int[] $createIds IDs of the filters to create + * @param array $actionParams Details of the action we need to execute to trigger filters + * @covers AbuseFilterRunner::checkFilter + * @covers AbuseFilterRunner::recordSlowFilter + * @dataProvider provideFiltersNoConsequences + */ + public function testTimeLimit( $createIds, $actionParams ) { + $loggerMock = new TestLogger(); + $loggerMock->setCollect( true ); + $this->setLogger( 'AbuseFilter', $loggerMock ); + $this->setMwGlobals( [ 'wgAbuseFilterSlowFilterRuntimeLimit' => -1 ] ); + + $this->createFilters( $createIds ); + // We don't care about consequences here + $this->doAction( $actionParams ); + + // Ensure slow filters are logged + $loggerBuffer = $loggerMock->getBuffer(); + $found = false; + foreach ( $loggerBuffer as $entry ) { + $check = preg_match( '/Edit filter [^ ]+ on [^ ]+ is taking longer than expected/', $entry[1] ); + if ( $check ) { + $found = true; + break; + } + } + $this->assertTrue( $found, 'The time limit hit was not logged.' ); + } + + /** + * Similar to self::provideFilters, but for tests where we don't care about consequences. + * + * @return array + */ + public function provideFiltersNoConsequences() { + return [ + [ + [ 1, 2 ], + [ + 'action' => 'edit', + 'target' => 'Test page', + 'oldText' => 'Some old text for the test.', + 'newText' => 'I like foo', + 'summary' => 'Test AbuseFilter for edit action.' + ] + ], + [ + [ 2 ], + [ + 'action' => 'move', + 'target' => 'Test page', + ] + ], + [ + [ 5 ], + [ + 'action' => 'edit', + 'target' => 'User:FilteredUser', + 'oldText' => 'Hey.', + 'newText' => 'I am a very nice user, really!', + 'summary' => '' + ] + ], + [ + [ 2, 3, 7, 8 ], + [ + 'action' => 'edit', + 'target' => 'Link', + 'oldText' => 'What is a link?', + 'newText' => 'A link is something like this: [[Link|]].', + 'summary' => 'Explaining' + ] + ], + [ + [ 8, 10 ], + [ + 'action' => 'edit', + 'target' => 'Anything', + 'oldText' => 'Bar', + 'newText' => 'Foo', + 'summary' => '' + ] + ], + [ + [ 2, 3 ], + [ + 'action' => 'delete', + 'target' => 'Test page' + ] + ], + [ + [ 10, 13 ], + [ + 'action' => 'edit', + 'target' => 'Tyger! Tyger! Burning bright', + 'oldText' => 'In the forests of the night', + 'newText' => 'What immortal hand or eye', + 'summary' => 'Could frame thy fearful symmetry?' + ] + ], + [ + [ 15 ], + [ + 'action' => 'createaccount', + 'target' => 'User:AnotherUser', + 'username' => 'AnotherUser' + ] + ] + ]; + } + + /** + * Check that hitting the throttle effectively updates abuse_filter.af_throttled. + * + * @param int[] $createIds IDs of the filters to create + * @param array $actionParams Details of the action we need to execute to trigger filters + * @covers AbuseFilterRunner::checkEmergencyDisable + * @dataProvider provideThrottleLimitFilters + */ + public function testThrottleLimit( $createIds, $actionParams ) { + $this->createFilters( $createIds ); + $this->setMwGlobals( [ + 'wgAbuseFilterEmergencyDisableCount' => [ 'default' => -1 ], + 'wgAbuseFilterEmergencyDisableThreshold' => [ 'default' => -1 ], + ] ); + // We don't care about consequences here + $this->doAction( $actionParams ); + + $throttled = []; + foreach ( $createIds as $filter ) { + $curThrottle = $this->db->selectField( + 'abuse_filter', + 'af_throttled', + [ 'af_id' => $filter ], + __METHOD__ + ); + if ( $curThrottle ) { + $throttled[] = $filter; + } + } + + $this->assertEquals( $createIds, $throttled, 'Some filters weren\'t automatically throttled.' ); + } + + /** + * Data provider for testThrottleLimit. Note that using filters with af_throttled = 1 in + * self::$filters makes the test case useless. + * + * @return array + */ + public function provideThrottleLimitFilters() { + return [ + [ + [ 1 ], + [ + 'action' => 'edit', + 'target' => 'Test page', + 'oldText' => 'Some old text for the test.', + 'newText' => 'I like foo', + 'summary' => 'Test AbuseFilter for edit action.' + ] + ], [ - 'Basic test for "move" action.', [ 2 ], [ - 'move', - 'Test page', + 'action' => 'move', + 'target' => 'Test page', 'newTitle' => 'Another test page' + ] + ], + [ + [ 5 ], + [ + 'action' => 'edit', + 'target' => 'User:FilteredUser', + 'oldText' => 'Hey.', + 'newText' => 'I am a very nice user, really!', + 'summary' => '' + ] + ], + [ + [ 7, 8 ], + [ + 'action' => 'edit', + 'target' => 'Link', + 'oldText' => 'What is a link?', + 'newText' => 'A link is something like this: [[Link|]].', + 'summary' => 'Explaining' + ] + ], + [ + [ 3 ], + [ + 'action' => 'delete', + 'target' => 'Test page' + ] + ], + [ + [ 15 ], + [ + 'action' => 'createaccount', + 'target' => 'User:AnotherUser', + 'username' => 'AnotherUser' + ] + ] + ]; + } + + /** + * Check an array of results from self::doAction to ensure that all but the last actions have been + * executed (i.e. no errors). + * @param Status[] $results As returned by self::doActions + * @return Status The Status of the last action, to be later checked with self::checkConsequences + */ + private function checkThrottleConsequence( $results ) { + $finalRes = array_pop( $results ); + foreach ( $results as $result ) { + if ( !$result->isGood() ) { + $this->fail( 'Only the last actions should have triggered a filter; the other ones ' . + 'should have been allowed.' ); + } + } + + return $finalRes; + } + + /** + * Like self::testFilterConsequences but for throttle, which deserves a special treatment + * + * @param int[] $createIds IDs of the filters to create + * @param array[] $actionsParams Details of the action we need to execute to trigger filters + * @param array $consequences The consequences we're expecting + * @dataProvider provideThrottleFilters + */ + public function testThrottle( $createIds, $actionsParams, $consequences ) { + $this->createFilters( $createIds ); + $results = self::doActions( $actionsParams ); + $res = $this->checkThrottleConsequence( $results ); + $lastParams = array_pop( $actionsParams ); + list( $expected, $actual ) = $this->checkConsequences( $res, $lastParams, $consequences ); + + $this->assertEquals( + $expected, + $actual, + 'The error messages obtained by performing the action do not match.' + ); + } + + /** + * Data provider for testThrottle. For every test case, we pass + * - an array with the IDs of the filters to be created (listed in self::$filters), + * - an array of array, where every sub-array holds the details of the action to execute in + * order to trigger the filters, each one like in self::provideFilters + * - an array of expected consequences for the last action (i.e. after throttling) of the form + * [ 'consequence name' => [ IDs of the filter to take its parameters from ] ] + * Such IDs may be more than one if we have a warning that is shown twice. + * + * + * @return array + */ + public function provideThrottleFilters() { + return [ + 'Basic test for throttling edits' => [ + [ 11 ], + [ + [ + 'action' => 'edit', + 'target' => 'Throttle', + 'oldText' => 'What is throttle?', + 'newText' => 'Throttle is something that should happen...', + 'summary' => 'Throttle' + ], + [ + 'action' => 'edit', + 'target' => 'Throttle', + 'oldText' => 'Throttle is something that should happen...', + 'newText' => '... Right now!', + 'summary' => 'Throttle' + ] ], - [ 'disallow' => [ 2 ], 'block' => [ 2 ] ], - [] + [ 'disallow' => [ 11 ] ] + ], + 'Basic test for throttling "move"' => [ + [ 11 ], + [ + [ + 'action' => 'move', + 'target' => 'Throttle test', + 'newTitle' => 'Another throttle test' + ], + [ + 'action' => 'move', + 'target' => 'Another throttle test', + 'newTitle' => 'Yet another throttle test' + ], + ], + [ 'disallow' => [ 11 ] ] + ], + 'Basic test for throttling "delete"' => [ + [ 11 ], + [ + [ + 'action' => 'delete', + 'target' => 'Test page' + ], + [ + 'action' => 'delete', + 'target' => 'Test page' + ] + ], + [ 'disallow' => [ 11 ] ] + ], + 'Basic test for throttling "createaccount"' => [ + [ 11 ], + [ + [ + 'action' => 'createaccount', + 'target' => 'User:AnotherUser', + 'username' => 'AnotherUser' + ], + [ + 'action' => 'createaccount', + 'target' => 'User:YetAnotherUser', + 'username' => 'YetAnotherUser' + ] + ], + [ 'disallow' => [ 11 ] ] + ], + ]; + } + + /** + * Test storing and loading the var dump. See also AbuseFilterDBTest::testVarDump + * + * @param int[] $createIds IDs of the filters to create + * @param array $actionParams Details of the action we need to execute to trigger filters + * @param string[] $usedVars The variables effectively computed by filters in $createIds. + * We'll search these in the stored dump. + * @covers AbuseFilter::storeVarDump + * @covers AbuseFilter::loadVarDump + * @covers AbuseFilterVariableHolder::dumpAllVars + * @dataProvider provideFiltersAndVariables + */ + public function testVarDump( $createIds, $actionParams, $usedVars ) { + $this->createFilters( $createIds ); + // We don't care about consequences here + $this->doAction( $actionParams ); + + // We just take a dump from a single filters, as they're all identical for the same action + $dumpID = $this->db->selectField( + 'abuse_filter_log', + 'afl_var_dump', + '', + __METHOD__, + [ 'ORDER BY' => 'afl_timestamp DESC' ] + ); + + $vars = AbuseFilter::loadVarDump( $dumpID )->getVars(); + + $interestingVars = array_intersect_key( $vars, array_fill_keys( $usedVars, true ) ); + + sort( $usedVars ); + ksort( $interestingVars ); + $this->assertEquals( + $usedVars, + array_keys( $interestingVars ), + "The saved variables aren't the expected ones." + ); + $this->assertContainsOnlyInstancesOf( + AFPData::class, + $interestingVars, + 'Some variables have not been computed.' + ); + } + + /** + * Data provider for testVarDump + * + * @return array + */ + public function provideFiltersAndVariables() { + return [ + [ + [ 1, 2 ], + [ + 'action' => 'edit', + 'target' => 'Test page', + 'oldText' => 'Some old text for the test.', + 'newText' => 'I like foo', + 'summary' => 'Test AbuseFilter for edit action.' + ], + [ 'added_lines', 'action' ] + ], + [ + [ 1, 2 ], + [ + 'action' => 'stashedit', + 'target' => 'Test page', + 'oldText' => 'Some old text for the test.', + 'newText' => 'I like foo', + 'summary' => 'Test AbuseFilter for edit action.', + 'stashType' => 'hit' + ], + [ 'added_lines', 'action' ] + ], + [ + [ 1, 2 ], + [ + 'action' => 'stashedit', + 'target' => 'Test page', + 'oldText' => 'Some old text for the test.', + 'newText' => 'I like foo', + 'summary' => 'Test AbuseFilter for edit action.', + 'stashType' => 'miss' + ], + [ 'added_lines', 'action' ] + ], + [ + [ 2 ], + [ + 'action' => 'move', + 'target' => 'Test page', + 'newTitle' => 'Another test page' + ], + [ 'action', 'moved_to_title' ] + ], + [ + [ 5 ], + [ + 'action' => 'edit', + 'target' => 'User:FilteredUser', + 'oldText' => 'Hey.', + 'newText' => 'I am a very nice user, really!', + 'summary' => '' + ], + [ 'user_name' ] + ], + [ + [ 2, 3, 7, 8 ], + [ + 'action' => 'edit', + 'target' => 'Link', + 'oldText' => 'What is a link?', + 'newText' => 'A link is something like this: [[Link|]].', + 'summary' => 'Explaining' + ], + [ 'action', 'timestamp', 'added_lines_pst' ] + ], + [ + [ 2, 3, 7, 8 ], + [ + 'action' => 'stashedit', + 'target' => 'Link', + 'oldText' => 'What is a link?', + 'newText' => 'A link is something like this: [[Link|]].', + 'summary' => 'Explaining', + 'stashType' => 'hit' + ], + [ 'action', 'timestamp', 'added_lines_pst' ] + ], + [ + [ 2, 3, 7, 8 ], + [ + 'action' => 'stashedit', + 'target' => 'Link', + 'oldText' => 'What is a link?', + 'newText' => 'A link is something like this: [[Link|]].', + 'summary' => 'Explaining', + 'stashType' => 'miss' + ], + [ 'action', 'timestamp', 'added_lines_pst' ] + ], + [ + [ 8, 10 ], + [ + 'action' => 'edit', + 'target' => 'Anything', + 'oldText' => 'Bar', + 'newText' => 'Foo', + 'summary' => '' + ], + [ 'added_lines_pst' ] ], [ - 'Basic test for "delete" action.', [ 2, 3 ], [ - 'delete', - 'Test page' + 'action' => 'delete', + 'target' => 'Test page' + ], + [ 'action', 'page_prefixedtitle' ] + ], + [ + [ 10, 13 ], + [ + 'action' => 'edit', + 'target' => 'Tyger! Tyger! Burning bright', + 'oldText' => 'In the forests of the night', + 'newText' => 'What immortal hand or eye', + 'summary' => 'Could frame thy fearful symmetry?' ], - [ 'degroup' => [ 3 ] ], [] ], [ - 'Basic test for "createaccount" action.', - [ 1, 2, 3, 4 ], + [ 15 ], [ - 'createaccount', - null, + 'action' => 'createaccount', + 'target' => 'User:AnotherUser', 'username' => 'AnotherUser' ], - [], - [] + [ 'action' ] + ], + [ + [ 16 ], + [ + 'action' => 'edit', + 'target' => 'Random', + 'oldText' => 'Old text', + 'newText' => 'Some new text which will not match', + 'summary' => 'No summary' + ], + [ 'edit_diff_pst', 'new_pst', 'new_html' ] + ], + [ + [ 16 ], + [ + 'action' => 'stashedit', + 'target' => 'Random', + 'oldText' => 'Old text', + 'newText' => 'Some new text which will not match', + 'summary' => 'No summary', + 'stashType' => 'miss' + ], + [ 'edit_diff_pst', 'new_pst', 'new_html' ] + ], + [ + [ 16 ], + [ + 'action' => 'stashedit', + 'target' => 'Random', + 'oldText' => 'Old text', + 'newText' => 'Some new text which will not match', + 'summary' => 'No summary', + 'stashType' => 'hit' + ], + [ 'edit_diff_pst', 'new_pst', 'new_html' ] + ], + ]; + } + + /** + * Same as testFilterConsequences but only for stashed edits + * + * @param string $type Either "hit" or "miss". The former saves the edit from stash, the second + * stashes the edit but doesn't reuse it. + * @param int[] $createIds IDs of the filters to create + * @param array $actionParams Details of the action we need to execute to trigger filters + * @param array $consequences The consequences we're expecting + * @dataProvider provideStashedEdits + */ + public function testStashedEdit( $type, $createIds, $actionParams, $consequences ) { + if ( $type !== 'hit' && $type !== 'miss' ) { + throw new InvalidArgumentException( '$type must be either "hit" or "miss"' ); + } + // Add some info in actionParams identical for all tests + $actionParams['action'] = 'stashedit'; + $actionParams['stashType'] = $type; + + $loggerMock = new TestLogger(); + $loggerMock->setCollect( true ); + $this->setLogger( 'StashEdit', $loggerMock ); + + $this->createFilters( $createIds ); + $result = $this->doAction( $actionParams ); + + // Check that we stored the edit and then hit/missed the cache + $foundStore = false; + $foundHitOrMiss = false; + // The conversion back and forth is needed because if the wiki language is not english + // the given namespace has been localized and thus wouldn't match. + $pageName = Title::newFromText( $actionParams['target'] )->getPrefixedText(); + foreach ( $loggerMock->getBuffer() as $entry ) { + if ( preg_match( "/AbuseFilterRunner::logCache: cache $type for '$pageName'/", $entry[1] ) ) { + $foundHitOrMiss = true; + } + if ( preg_match( "/AbuseFilterRunner::logCache: cache store for '$pageName'/", $entry[1] ) ) { + $foundStore = true; + } + if ( $foundStore && $foundHitOrMiss ) { + break; + } + } + if ( !$foundStore ) { + $this->fail( 'Did not store the edit in cache as expected for a stashed edit.' ); + } elseif ( !$foundHitOrMiss ) { + $this->fail( "Did not $type the cache as expected for a stashed edit." ); + } + + list( $expected, $actual ) = $this->checkConsequences( $result, $actionParams, $consequences ); + + $this->assertEquals( + $expected, + $actual, + 'The error messages obtained by performing the action do not match.' + ); + } + + /** + * Data provider for testStashedEdit + * + * @return array + */ + public function provideStashedEdits() { + $sets = [ + [ + [ 1, 2 ], + [ + 'target' => 'Test page', + 'oldText' => 'Some old text for the test.', + 'newText' => 'I like foo', + 'summary' => 'Test AbuseFilter for edit action.' + ], + [ 'warn' => [ 1 ] ] ], [ - 'Test to check that all tags are applied.', [ 5 ], [ - 'edit', - 'User:FilteredUser', + 'target' => 'User:FilteredUser', 'oldText' => 'Hey.', 'newText' => 'I am a very nice user, really!', 'summary' => '' ], - [ 'tag' => [ 5 ] ], - [] + [ 'tag' => [ 5 ] ] ], [ - 'Test to check that the edit is disallowed.', [ 6 ], [ - 'edit', - 'Help:Help', + 'target' => 'Help:Help', 'oldText' => 'Some help.', 'newText' => 'Some help for you', 'summary' => 'Help! I need somebody' ], - [ 'disallow' => [ 6 ] ], - [] + [ 'disallow' => [ 6 ] ] ], [ - 'Test to check that degroup and block are executed together.', [ 2, 3, 7, 8 ], [ - 'edit', - 'Link', + 'target' => 'Link', 'oldText' => 'What is a link?', 'newText' => 'A link is something like this: [[Link|]].', 'summary' => 'Explaining' ], - [ 'degroup' => [ 7 ], 'block' => [ 8 ] ], - [] + [ 'degroup' => [ 7 ], 'block' => [ 8 ] ] ], [ - 'Test to check that the block duration is the longest one.', [ 8, 9 ], [ - 'edit', - 'Whatever', + 'target' => 'Whatever', 'oldText' => 'Whatever is whatever', 'newText' => 'Whatever is whatever, whatever it is. BTW, here is a [[Link|]]', 'summary' => 'Whatever' ], - [ 'disallow' => [ 8 ], 'block' => [ 8 ] ], - [] + [ 'disallow' => [ 8 ], 'block' => [ 8 ] ] ], [ - 'Test to check that throttled filters only execute "safe" actions.', [ 10 ], [ - 'edit', - 'Buffalo', + 'target' => 'Buffalo', 'oldText' => 'Buffalo', 'newText' => 'Buffalo buffalo Buffalo buffalo buffalo buffalo Buffalo buffalo.', 'summary' => 'Buffalo!' ], - [ 'tag' => [ 10 ] ], - [] + [ 'tag' => [ 10 ] ] ], [ - 'Test to see that throttling works well.', - [ 11 ], - [ - 'edit', - 'Throttle', - 'oldText' => 'What is throttle?', - 'firstNewText' => 'Throttle is something that should happen...', - 'secondNewText' => '... Right now!', - 'summary' => 'Throttle' - ], - [ 'throttle' => [ 11 ], 'disallow' => [ 11 ] ], - [ 'makeGoodEditFirst' ] - ], - [ - 'Test to check that degroup and block are both executed and degroup warning is shown twice.', [ 1, 3, 7, 12 ], [ - 'edit', - 'User:FilteredUser', + 'target' => 'User:FilteredUser', 'oldText' => '', 'newText' => 'A couple of lines about me...', 'summary' => 'My user page' ], - [ 'block' => [ 12 ], 'degroup' => [ 7, 12 ] ], - [] + [ 'block' => [ 12 ], 'degroup' => [ 7, 12 ] ] ], [ - 'Test to check that every throttled filter only executes "safe" actions.', [ 10, 13 ], [ - 'edit', - 'Tyger! Tyger! Burning bright', + 'target' => 'Tyger! Tyger! Burning bright', 'oldText' => 'In the forests of the night', 'newText' => 'What immortal hand or eye', 'summary' => 'Could frame thy fearful symmetry?' ], - [ 'tag' => [ 10 ] ], - [] + [ 'tag' => [ 10 ] ] ], [ - 'Test to check that runtime exceptions (division by zero) are correctly handled.', [ 14 ], [ - 'edit', - '0', + 'target' => '0', 'oldText' => 'Old text', 'newText' => 'New text', 'summary' => 'Some summary' ], - [], [] ], [ - 'Test to check that the conditions limit works.', [ 8, 10 ], [ - 'edit', - 'Anything', + 'target' => 'Anything', 'oldText' => 'Bar', 'newText' => 'Foo', 'summary' => '' ], - [], - [ 'hitCondsLimit' ] + [] ], [ - 'Test slow executions.', [ 7, 12 ], [ - 'edit', - 'Something', + 'target' => 'Something', 'oldText' => 'Please allow me', 'newText' => 'to introduce myself', 'summary' => '' ], - [ 'degroup' => [ 7 ] ], - [ 'hitTimeLimit' ] + [ 'degroup' => [ 7 ] ] ], [ - 'Test throttling a dangerous filter.', [ 13 ], [ - 'edit', - 'My page', + 'target' => 'My page', 'oldText' => '', 'newText' => 'AbuseFilter will not block me', 'summary' => '' ], - [], - [ 'hitThrottleLimit' ] + [] + ], + ]; + + $finalSets = []; + foreach ( $sets as $set ) { + // Test both successfully saving a stashed edit and stashing the edit but re-executing filters + $finalSets[] = array_merge( [ 'miss' ], $set ); + $finalSets[] = array_merge( [ 'hit' ], $set ); + } + return $finalSets; + } + + /** + * Test filter profiling, both for total and per-filter stats. NOTE: This test performs several + * actions for every test set, and is thus HEAVY. + * + * @param int[] $createIds IDs of the filters to create + * @param array $actionParams Details of the action we need to execute to trigger filters + * @param array $expectedGlobal Expected global stats + * @param array $expectedPerFilter Expected stats for every created filter + * @covers AbuseFilter::filterProfileKey + * @covers AbuseFilter::filterProfileGroupKey + * @covers AbuseFilter::getFilterProfile + * @covers AbuseFilterRunner::checkAllFilters + * @covers AbuseFilterRunner::recordStats + * @dataProvider provideProfilingFilters + */ + public function testProfiling( $createIds, $actionParams, $expectedGlobal, $expectedPerFilter ) { + $this->setMwGlobals( [ + 'wgAbuseFilterConditionLimit' => $actionParams[ 'condsLimit' ] + ] ); + $this->createFilters( $createIds ); + for ( $i = 1; $i <= $actionParams['repeatAction'] - 1; $i++ ) { + // First make some other actions to increase stats + // @ToDo This doesn't works well with account creations + $this->doAction( $actionParams ); + $actionParams['target'] .= $i; + } + // This is the magic value used by filter 16 to change the amount of used condition + MWTimestamp::setFakeTime( self::MAGIC_TIMESTAMP ); + // We don't care about consequences here + $this->doAction( $actionParams ); + MWTimestamp::setFakeTime( false ); + + $stash = MediaWikiServices::getInstance()->getMainObjectStash(); + // Global stats shown on the top of Special:AbuseFilter + $globalStats = $stash->get( AbuseFilter::filterProfileGroupKey( 'default' ) ); + $actualGlobalStats = [ + 'totalMatches' => $globalStats['matches'], + 'totalActions' => $globalStats['total'], + 'totalOverflows' => $globalStats['overflow'] + ]; + $this->assertSame( + $expectedGlobal, + $actualGlobalStats, + 'Global profiling stats are not computed correctly.' + ); + + // Per-filter stats shown on the top of Special:AbuseFilter/xxx + foreach ( $createIds as $id ) { + list( $totalActions, $matches, , $conds ) = AbuseFilter::getFilterProfile( $id ); + $actualStats = [ + 'matches' => $matches, + 'actions' => $totalActions, + 'averageConditions' => $conds + ]; + $this->assertSame( + $expectedPerFilter[ $id ], + $actualStats, + "Profiling stats are not computed correctly for filter $id." + ); + } + } + + /** + * Data provider for testProfiling. We only want filters which let the edit pass, since + * we'll perform multiple edits. How this test works: we repeat the action X times. For 1 to + * X - 1, it would take 1 + 1 + 5 + 1 conditions, but it will overflow without checking filter + * 19 (since the conds limit is 7). Then we perform the last execution using a trick that will + * make filter 17 only consume 1 condition. + * + * @todo All these values should be more customizable, or just hardcoded in the test method. + * + * @return array + */ + public function provideProfilingFilters() { + return [ + 'Basic test for statistics recording on edit.' => [ + [ 4, 5, 17, 19 ], + [ + 'action' => 'edit', + 'target' => 'Some page', + 'oldText' => 'Some old text', + 'newText' => 'Some new text', + 'summary' => 'Some summary', + 'condsLimit' => 7, + 'repeatAction' => 3 + ], + [ + 'totalMatches' => 3, + 'totalActions' => 3, + 'totalOverflows' => 2 + ], + [ + 4 => [ + 'matches' => 0, + 'actions' => 3, + 'averageConditions' => 1.0 + ], + 5 => [ + 'matches' => 3, + 'actions' => 3, + 'averageConditions' => 1.0 + ], + 17 => [ + 'matches' => 1, + 'actions' => 3, + 'averageConditions' => 3.7 + ], + 19 => [ + 'matches' => 1, + 'actions' => 3, + 'averageConditions' => 1.0 + ], + ] + ], + 'Test for statistics recording on a successfully stashed edit.' => [ + [ 4, 5, 17, 19 ], + [ + 'action' => 'stashedit', + 'target' => 'Some page', + 'oldText' => 'Some old text', + 'newText' => 'Some new text', + 'summary' => 'Some summary', + 'stashType' => 'hit', + 'condsLimit' => 7, + 'repeatAction' => 3 + ], + [ + 'totalMatches' => 3, + 'totalActions' => 3, + 'totalOverflows' => 2 + ], + [ + 4 => [ + 'matches' => 0, + 'actions' => 3, + 'averageConditions' => 1.0 + ], + 5 => [ + 'matches' => 3, + 'actions' => 3, + 'averageConditions' => 1.0 + ], + 17 => [ + 'matches' => 1, + 'actions' => 3, + 'averageConditions' => 3.7 + ], + 19 => [ + 'matches' => 1, + 'actions' => 3, + 'averageConditions' => 1.0 + ], + ] + ], + 'Test for statistics recording on an unsuccessfully stashed edit.' => [ + [ 4, 5, 17, 19 ], + [ + 'action' => 'stashedit', + 'target' => 'Some page', + 'oldText' => 'Some old text', + 'newText' => 'Some new text', + 'summary' => 'Some summary', + 'stashType' => 'miss', + 'condsLimit' => 7, + 'repeatAction' => 3 + ], + [ + 'totalMatches' => 3, + 'totalActions' => 3, + 'totalOverflows' => 2 + ], + [ + 4 => [ + 'matches' => 0, + 'actions' => 3, + 'averageConditions' => 1.0 + ], + 5 => [ + 'matches' => 3, + 'actions' => 3, + 'averageConditions' => 1.0 + ], + 17 => [ + 'matches' => 1, + 'actions' => 3, + 'averageConditions' => 3.7 + ], + 19 => [ + 'matches' => 1, + 'actions' => 3, + 'averageConditions' => 1.0 + ], + ] + ] + ]; + } + + /** + * Tests for global filters, defined on a central wiki and executed on another (e.g. a filter + * defined on meta but triggered on another wiki using meta's global filters). + * We emulate an external database by using different tables prefixed with + * self::DB_EXTERNAL_PREFIX + * + * @param int[] $createIds IDs of the filters to create + * @param array $actionParams Details of the action we need to execute to trigger filters + * @param array $consequences The consequences we're expecting + * @dataProvider provideGlobalFilters + */ + public function testGlobalFilters( $createIds, $actionParams, $consequences ) { + $this->createFilters( $createIds, true ); + + $result = $this->doAction( $actionParams ); + + list( $expected, $actual ) = $this->checkConsequences( $result, $actionParams, $consequences ); + + // First check that the filter work as expected + $this->assertEquals( + $expected, + $actual, + 'The error messages obtained by performing the action do not match.' + ); + + // Check that the hits were logged on the "external" DB + $logged = $this->db->selectFieldValues( + self::DB_EXTERNAL_PREFIX . 'abuse_filter_log', + 'afl_filter', + [ 'afl_wiki IS NOT NULL' ], + __METHOD__ + ); + // Don't use assertSame because the DB holds strings here (T42757) + $this->assertEquals( + $createIds, + $logged, + 'Some filter hits were not logged in the external DB.' + ); + } + + /** + * Data provider for testGlobalFilters + * + * @return array + */ + public function provideGlobalFilters() { + return [ + [ + [ 18 ], + [ + 'action' => 'edit', + 'target' => 'Global', + 'oldText' => 'Old text', + 'newText' => 'New text', + 'summary' => '' + ], + [ 'disallow' => [ 18 ], 'warn' => [ 18 ] ] ], + [ + [ 19 ], + [ + 'action' => 'edit', + 'target' => 'A global page', + 'oldText' => 'Foo', + 'newText' => 'Bar', + 'summary' => 'Baz' + ], + [ 'tag' => [ 19 ] ] + ], + [ + [ 19, 20 ], + [ + 'action' => 'edit', + 'target' => 'Cellar door', + 'oldText' => '', + 'newText' => 'Yay, that\'s cool', + 'summary' => 'Unit test' + ], + [ 'disallow' => [ 20 ] ] + ], + [ + [ 18 ], + [ + 'action' => 'move', + 'target' => 'Cellar door', + 'newTitle' => 'Attic door' + ], + [ 'warn' => [ 18 ] ] + ], + [ + [ 19, 20 ], + [ + 'action' => 'delete', + 'target' => 'Cellar door', + ], + [ 'disallow' => [ 20 ] ] + ], + [ + [ 19 ], + [ + 'action' => 'stashedit', + 'target' => 'Cellar door', + 'oldText' => '', + 'newText' => 'Too many doors', + 'summary' => '', + 'stashType' => 'hit' + ], + [ 'tag' => [ 19 ] ] + ], + [ + [ 18 ], + [ + 'action' => 'createaccount', + 'target' => 'User:AbuseFilterGlobalUser', + 'username' => 'AbuseFilterGlobalUser' + ], + [ 'warn' => [ 18 ] ] + ] ]; } } |