Expand SearchFilters.php to search by consequence

Why:
* SearchFilters.php allows the caller to search by a regex that
  is applied to the pattern.
* This script can be expanded to allow callers to specify what
  consequence should be associated with the filters that are
  outputted.

What:
* Add a 'consequence' option to the SearchFilters.php maintenance
  script, which is applied through a LIKE query on the
  af_actions column.
** This can be specified with or without the pattern option.
** Instead of making pattern required, the script now requires
   that one of consequence or pattern is provided.
* Expand the tests for the script for this new code, along with
  using the new ::expectCallToFatalError method to be able to
  test previously untestable code.

Bug: T373148
Change-Id: I1b507d8f9dc1f4cf91ee4f83ccde745eb6d46d6d
This commit is contained in:
Dreamy Jazz 2024-08-22 22:01:16 +01:00
parent 86d4fed611
commit 7ecc204050
2 changed files with 74 additions and 43 deletions

View file

@ -3,6 +3,9 @@
namespace MediaWiki\Extension\AbuseFilter\Maintenance;
use Maintenance;
use MediaWiki\MainConfigNames;
use Wikimedia\Rdbms\IExpression;
use Wikimedia\Rdbms\LikeValue;
// @codeCoverageIgnoreStart
$IP = getenv( 'MW_INSTALL_PATH' );
@ -15,8 +18,11 @@ require_once "$IP/maintenance/Maintenance.php";
class SearchFilters extends Maintenance {
public function __construct() {
parent::__construct();
$this->addDescription( 'Find all filters matching a regular expression pattern' );
$this->addOption( 'pattern', 'Regular expression pattern', true, true );
$this->addDescription(
'Find all filters matching a regular expression pattern and/or that have given consequence'
);
$this->addOption( 'pattern', 'Regular expression pattern', false, true );
$this->addOption( 'consequence', 'The consequence that the filter should have', false, true );
$this->requireExtension( 'Abuse Filter' );
}
@ -25,22 +31,17 @@ class SearchFilters extends Maintenance {
* @see Maintenance:execute()
*/
public function execute() {
global $wgConf, $wgDBtype;
global $wgConf;
if ( $wgDBtype !== 'mysql' ) {
// Code using exit() cannot be tested (T272241)
// @codeCoverageIgnoreStart
if ( $this->getConfig()->get( MainConfigNames::DBtype ) !== 'mysql' ) {
$this->fatalError( 'This maintenance script only works with MySQL databases' );
// @codeCoverageIgnoreEnd
}
if ( !$this->getOption( 'pattern' ) && !$this->getOption( 'consequence' ) ) {
$this->fatalError( 'One of --consequence or --pattern should be specified.' );
}
$this->output( "wiki\tfilter\n" );
if ( $this->getOption( 'pattern' ) === '' ) {
// Code using exit() cannot be tested (T272241)
// @codeCoverageIgnoreStart
$this->fatalError( 'Pattern cannot be empty' );
// @codeCoverageIgnoreEnd
}
if ( count( $wgConf->wikis ) > 0 ) {
foreach ( $wgConf->wikis as $dbname ) {
@ -57,16 +58,23 @@ class SearchFilters extends Maintenance {
private function getMatchingFilters( $dbname = false ) {
$dbr = $this->getDB( DB_REPLICA, [], $dbname );
$pattern = $dbr->addQuotes( $this->getOption( 'pattern' ) );
$consequence = $this->getOption( 'consequence' );
if ( $dbr->tableExists( 'abuse_filter' ) ) {
$rows = $dbr->newSelectQueryBuilder()
$queryBuilder = $dbr->newSelectQueryBuilder()
->select( [ 'dbname' => 'DATABASE()', 'af_id' ] )
->from( 'abuse_filter' )
->where( [
"af_pattern RLIKE $pattern"
] )
->caller( __METHOD__ )
->fetchResultSet();
->from( 'abuse_filter' );
if ( $pattern ) {
$queryBuilder->where( "af_pattern RLIKE $pattern" );
}
if ( $consequence ) {
$queryBuilder->where( $dbr->expr(
'af_actions',
IExpression::LIKE,
new LikeValue( $dbr->anyString(), $consequence, $dbr->anyString() )
) );
}
$rows = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
foreach ( $rows as $row ) {
$this->output( $row->dbname . "\t" . $row->af_id . "\n" );

View file

@ -5,6 +5,7 @@ namespace MediaWiki\Extension\AbuseFilter\Tests\Integration;
use Generator;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\Maintenance\SearchFilters;
use MediaWiki\MainConfigNames;
use MediaWiki\Tests\Maintenance\MaintenanceBaseTestCase;
/**
@ -14,17 +15,6 @@ use MediaWiki\Tests\Maintenance\MaintenanceBaseTestCase;
* @covers \MediaWiki\Extension\AbuseFilter\Maintenance\SearchFilters
*/
class SearchFiltersTest extends MaintenanceBaseTestCase {
protected function setUp(): void {
global $wgDBtype;
parent::setUp();
if ( $wgDBtype !== 'mysql' ) {
$this->markTestSkipped( 'The script only works on MySQL' );
}
}
/**
* @inheritDoc
*/
@ -46,15 +36,16 @@ class SearchFiltersTest extends MaintenanceBaseTestCase {
'af_hit_count' => 0,
'af_throttled' => 0,
'af_deleted' => 0,
'af_actions' => '',
'af_global' => 0,
'af_group' => 'default'
];
$rows = [
[ 'af_id' => 1, 'af_pattern' => '' ] + $defaultRow,
[ 'af_id' => 2, 'af_pattern' => 'rmspecials(page_title) === "foo"' ] + $defaultRow,
[ 'af_id' => 3, 'af_pattern' => 'user_editcount % 3 !== 1' ] + $defaultRow,
[ 'af_id' => 4, 'af_pattern' => 'rmspecials(added_lines_pst) !== ""' ] + $defaultRow
[ 'af_id' => 1, 'af_pattern' => '', 'af_actions' => '' ] + $defaultRow,
[ 'af_id' => 2, 'af_pattern' => 'rmspecials(page_title) === "foo"', 'af_actions' => 'warn' ] + $defaultRow,
[ 'af_id' => 3, 'af_pattern' => 'user_editcount % 3 !== 1', 'af_actions' => 'warn,block' ] + $defaultRow,
[
'af_id' => 4, 'af_pattern' => 'rmspecials(added_lines_pst) !== ""', 'af_actions' => 'block',
] + $defaultRow
];
$this->getDb()->newInsertQueryBuilder()
->insertInto( 'abuse_filter' )
@ -63,6 +54,29 @@ class SearchFiltersTest extends MaintenanceBaseTestCase {
->execute();
}
/** @dataProvider provideNonMySQLDatabaseTypes */
public function testExecuteForNonMySQLDatabaseType( $dbType ) {
$this->expectCallToFatalError();
$this->expectOutputString( "This maintenance script only works with MySQL databases\n" );
$this->overrideConfigValue( MainConfigNames::DBtype, $dbType );
$this->maintenance->execute();
}
public static function provideNonMySQLDatabaseTypes() {
return [
'PostgresSQL' => [ 'postgres' ],
'SQLite' => [ 'sqlite' ],
];
}
public function testExecuteWhenNeitherPatternOrConsequenceProvided() {
// It is safe to mock the DB type here, as the script should exit before any queries are made
$this->overrideConfigValue( MainConfigNames::DBtype, 'mysql' );
$this->expectCallToFatalError();
$this->expectOutputString( "One of --consequence or --pattern should be specified.\n" );
$this->maintenance->execute();
}
private function getExpectedOutput( array $ids, bool $withHeader = true ): string {
global $wgDBname;
$expected = $withHeader ? "wiki\tfilter\n" : '';
@ -73,32 +87,41 @@ class SearchFiltersTest extends MaintenanceBaseTestCase {
}
public static function provideSearches(): Generator {
yield 'single filter' => [ 'page_title', [ 2 ] ];
yield 'multiple filters' => [ 'rmspecials', [ 2, 4 ] ];
yield 'regex' => [ '[a-z]\(', [ 2, 4 ] ];
yield 'single filter for pattern search' => [ 'page_title', '', [ 2 ] ];
yield 'multiple filters for pattern search' => [ 'rmspecials', '', [ 2, 4 ] ];
yield 'single filter when consequence specified' => [ 'rmspecials', 'block', [ 4 ] ];
yield 'regex for pattern' => [ '[a-z]\(', '', [ 2, 4 ] ];
}
/**
* @param string $pattern
* @param string $consequence
* @param array $expectedIDs
* @dataProvider provideSearches
*/
public function testExecute_singleWiki( string $pattern, array $expectedIDs ) {
public function testExecute_singleWiki( string $pattern, string $consequence, array $expectedIDs ) {
if ( $this->getDb()->getType() !== 'mysql' ) {
$this->markTestSkipped( 'The script only works on MySQL' );
}
$this->setMwGlobals( [ 'wgConf' => (object)[ 'wikis' => [] ] ] );
$this->maintenance->loadParamsAndArgs( null, [ 'pattern' => $pattern ] );
$this->maintenance->loadParamsAndArgs( null, [ 'pattern' => $pattern, 'consequence' => $consequence ] );
$this->expectOutputString( $this->getExpectedOutput( $expectedIDs ) );
$this->maintenance->execute();
}
/**
* @param string $pattern
* @param string $consequence
* @param array $expectedIDs
* @dataProvider provideSearches
*/
public function testExecute_multipleWikis( string $pattern, array $expectedIDs ) {
public function testExecute_multipleWikis( string $pattern, string $consequence, array $expectedIDs ) {
if ( $this->getDb()->getType() !== 'mysql' ) {
$this->markTestSkipped( 'The script only works on MySQL' );
}
global $wgDBname;
$this->setMwGlobals( [ 'wgConf' => (object)[ 'wikis' => [ $wgDBname, $wgDBname ] ] ] );
$this->maintenance->loadParamsAndArgs( null, [ 'pattern' => $pattern ] );
$this->maintenance->loadParamsAndArgs( null, [ 'pattern' => $pattern, 'consequence' => $consequence ] );
$expectedText = $this->getExpectedOutput( $expectedIDs ) . $this->getExpectedOutput( $expectedIDs, false );
$this->expectOutputString( $expectedText );
$this->maintenance->execute();