Viewing File: /home/omtekel/www/wp-content/upgrade/backup/integration.tar

Maintenance/PurgeOldLogIPDataTest.php000066600000003760151334735100013561 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration;

use HashConfig;
use MediaWiki\Extension\AbuseFilter\Maintenance\PurgeOldLogIPData;
use MediaWiki\Tests\Maintenance\MaintenanceBaseTestCase;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group Test
 * @group AbuseFilter
 * @group Database
 * @covers \MediaWiki\Extension\AbuseFilter\Maintenance\PurgeOldLogIPData
 */
class PurgeOldLogIPDataTest extends MaintenanceBaseTestCase {

	private const FAKE_TIME = '20200115000000';
	private const MAX_AGE = 3600;

	/** @inheritDoc */
	protected $tablesUsed = [ 'abuse_filter_log' ];

	/**
	 * @inheritDoc
	 */
	protected function getMaintenanceClass() {
		return PurgeOldLogIPData::class;
	}

	/**
	 * @inheritDoc
	 */
	public function addDBData() {
		$defaultRow = [
			'afl_ip' => '1.1.1.1',
			'afl_global' => 0,
			'afl_filter_id' => 1,
			'afl_user' => 1,
			'afl_user_text' => 'User',
			'afl_action' => 'edit',
			'afl_actions' => '',
			'afl_var_dump' => 'xxx',
			'afl_namespace' => 0,
			'afl_title' => 'Title',
			'afl_wiki' => null,
			'afl_deleted' => 0,
			'afl_patrolled_by' => 0,
			'afl_rev_id' => 42,
		];
		$oldTS = ConvertibleTimestamp::convert(
			TS_MW,
			ConvertibleTimestamp::convert( TS_UNIX, self::FAKE_TIME ) - 2 * self::MAX_AGE
		);
		$rows = [
			[ 'afl_id' => 1, 'afl_timestamp' => $this->db->timestamp( $oldTS ) ] + $defaultRow,
			[ 'afl_id' => 2, 'afl_timestamp' => $this->db->timestamp( $oldTS ), 'afl_ip' => '' ] + $defaultRow,
			[ 'afl_id' => 3, 'afl_timestamp' => $this->db->timestamp( self::FAKE_TIME ) ] + $defaultRow,
			[ 'afl_id' => 4, 'afl_timestamp' => $this->db->timestamp( self::FAKE_TIME ), 'afl_ip' => '' ] + $defaultRow,
		];
		$this->db->insert( 'abuse_filter_log', $rows, __METHOD__ );
	}

	public function testExecute() {
		ConvertibleTimestamp::setFakeTime( self::FAKE_TIME );
		$this->maintenance->setConfig( new HashConfig( [ 'AbuseFilterLogIPMaxAge' => self::MAX_AGE ] ) );
		$this->expectOutputRegex( '/1 rows/' );
		$this->maintenance->execute();
	}
}
Maintenance/SearchFiltersTest.php000066600000005775151334735100013121 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration;

use Generator;
use MediaWiki\Extension\AbuseFilter\Maintenance\SearchFilters;
use MediaWiki\Tests\Maintenance\MaintenanceBaseTestCase;

/**
 * @group Test
 * @group AbuseFilter
 * @group Database
 * @covers \MediaWiki\Extension\AbuseFilter\Maintenance\SearchFilters
 */
class SearchFiltersTest extends MaintenanceBaseTestCase {

	/** @inheritDoc */
	protected $tablesUsed = [ 'abuse_filter' ];

	protected function setUp(): void {
		global $wgDBtype;

		parent::setUp();

		if ( $wgDBtype !== 'mysql' ) {
			$this->markTestSkipped( 'The script only works on MySQL' );
		}
	}

	/**
	 * @inheritDoc
	 */
	protected function getMaintenanceClass() {
		return SearchFilters::class;
	}

	/**
	 * @inheritDoc
	 */
	public function addDBData() {
		$defaultRow = [
			'af_user' => 0,
			'af_user_text' => 'FilterTester',
			'af_actor' => 1,
			'af_timestamp' => $this->db->timestamp( '20190826000000' ),
			'af_enabled' => 1,
			'af_comments' => '',
			'af_public_comments' => 'Test filter',
			'af_hidden' => 0,
			'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
		];
		$this->db->insert( 'abuse_filter', $rows, __METHOD__ );
	}

	private function getExpectedOutput( array $ids, bool $withHeader = true ): string {
		global $wgDBname;
		$expected = $withHeader ? "wiki\tfilter\n" : '';
		foreach ( $ids as $id ) {
			$expected .= "$wgDBname\t$id\n";
		}
		return $expected;
	}

	public static function provideSearches(): Generator {
		yield 'single filter' => [ 'page_title', [ 2 ] ];
		yield 'multiple filters' => [ 'rmspecials', [ 2, 4 ] ];
		yield 'regex' => [ '[a-z]\(', [ 2, 4 ] ];
	}

	/**
	 * @param string $pattern
	 * @param array $expectedIDs
	 * @dataProvider provideSearches
	 */
	public function testExecute_singleWiki( string $pattern, array $expectedIDs ) {
		$this->setMwGlobals( [ 'wgConf' => (object)[ 'wikis' => [] ] ] );
		$this->maintenance->loadParamsAndArgs( null, [ 'pattern' => $pattern ] );
		$this->expectOutputString( $this->getExpectedOutput( $expectedIDs ) );
		$this->maintenance->execute();
	}

	/**
	 * @param string $pattern
	 * @param array $expectedIDs
	 * @dataProvider provideSearches
	 */
	public function testExecute_multipleWikis( string $pattern, array $expectedIDs ) {
		global $wgDBname;
		$this->setMwGlobals( [ 'wgConf' => (object)[ 'wikis' => [ $wgDBname, $wgDBname ] ] ] );
		$this->maintenance->loadParamsAndArgs( null, [ 'pattern' => $pattern ] );
		$expectedText = $this->getExpectedOutput( $expectedIDs ) . $this->getExpectedOutput( $expectedIDs, false );
		$this->expectOutputString( $expectedText );
		$this->maintenance->execute();
	}
}
ChangeTags/ChangeTagValidatorTest.php000066600000002703151334735100013620 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration\ChangeTags;

use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use MediaWikiIntegrationTestCase;

/**
 * @group Test
 * @group AbuseFilter
 * @group Database
 * @covers \MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagValidator
 */
class ChangeTagValidatorTest extends MediaWikiIntegrationTestCase {
	/**
	 * @todo Make this a unit test once static methods in ChangeTags are moved to a service
	 * @todo When the above is possible, use mocks to test canAddTagsAccompanyingChange and canCreateTag
	 * @param string $tag The tag to validate
	 * @param string|null $expectedError
	 * @covers \MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagValidator::validateTag
	 * @dataProvider provideTags
	 */
	public function testValidateTag( string $tag, ?string $expectedError ) {
		$validator = AbuseFilterServices::getChangeTagValidator();
		$status = $validator->validateTag( $tag );
		$actualError = $status->isGood() ? null : $status->getErrors()[0]['message'];
		$this->assertSame( $expectedError, $actualError );
	}

	/**
	 * Data provider for testValidateTag
	 * @return array
	 */
	public static function provideTags() {
		return [
			'invalid chars' => [ 'a|b', 'tags-create-invalid-chars' ],
			'core-reserved tag' => [ 'mw-undo', 'abusefilter-edit-bad-tags' ],
			'AF-reserved tag' => [ 'abusefilter-condition-limit', 'abusefilter-tag-reserved' ],
			'valid' => [ 'my_tag', null ],
		];
	}
}
ActionVariablesIntegrationTest.php000066600000033717151334735100013410 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration;

use AbuseFilterCreateAccountTestTrait;
use ApiTestCase;
use ApiUsageException;
use Content;
use FormatJson;
use Generator;
use JsonContent;
use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use MediaWiki\Extension\AbuseFilter\AbuseLogger;
use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesLookup;
use MediaWiki\Extension\AbuseFilter\EditRevUpdater;
use MediaWiki\Extension\AbuseFilter\EmergencyCache;
use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\Filter\LastEditInfo;
use MediaWiki\Extension\AbuseFilter\Filter\Specs;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\FilterProfiler;
use MediaWiki\Extension\AbuseFilter\Hooks\Handlers\FilteredActionsHandler;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Watcher\EmergencyWatcher;
use MediaWiki\Extension\AbuseFilter\Watcher\UpdateHitCountWatcher;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use NullStatsdDataFactory;
use WikitextContent;

/**
 * @group Database
 * @group medium
 * @group AbuseFilter
 * @group AbuseFilterGeneric
 */
class ActionVariablesIntegrationTest extends ApiTestCase {
	use AbuseFilterCreateAccountTestTrait;

	public function setUp(): void {
		parent::setUp();

		$this->tablesUsed[] = 'externallinks';
		$this->tablesUsed[] = 'page';
		$this->tablesUsed[] = 'revision';
	}

	private function prepareServices(): void {
		$this->setService(
			FilterProfiler::SERVICE_NAME,
			$this->createMock( FilterProfiler::class )
		);
		$this->setService(
			EmergencyCache::SERVICE_NAME,
			$this->createMock( EmergencyCache::class )
		);
		$this->setService(
			EmergencyWatcher::SERVICE_NAME,
			$this->createMock( EmergencyWatcher::class )
		);
		$this->setService(
			UpdateHitCountWatcher::SERVICE_NAME,
			$this->createMock( UpdateHitCountWatcher::class )
		);
		$this->setService(
			EditRevUpdater::SERVICE_NAME,
			$this->createMock( EditRevUpdater::class )
		);

		$this->overrideConfigValues( [
			MainConfigNames::PageCreationLog => false,
			'AbuseFilterCentralDB' => false,
		] );

		$filter = new ExistingFilter(
			new Specs( '1 === 1', '', 'Test Filter', [ 'disallow' ], 'default' ),
			new Flags( true, false, false, false ),
			[ 'disallow' => [ 'abusefilter-disallow' ] ],
			new LastEditInfo( 1, 'Filter User', '20220713000000' ),
			1,
			0,
			false
		);
		$filterLookup = $this->createMock( FilterLookup::class );
		$filterLookup->expects( $this->once() )
			->method( 'getAllActiveFiltersInGroup' )
			->with( 'default', false )
			->willReturn( [ $filter ] );
		$filterLookup->method( 'getFilter' )
			->with( 1, false )
			->willReturn( $filter );
		$this->setService( FilterLookup::SERVICE_NAME, $filterLookup );

		$consequencesLookup = $this->createMock( ConsequencesLookup::class );
		$consequencesLookup->method( 'getConsequencesForFilters' )
			->with( $this->logicalOr( [ 1 ], [ '1' ] ) )
			->willReturn( [ 1 => $filter->getActions() ] );
		$this->setService( ConsequencesLookup::SERVICE_NAME, $consequencesLookup );
	}

	private function setAbuseLoggerFactoryWithEavesdrop( VariableHolder &$varHolder = null ): void {
		$factory = $this->createMock( AbuseLoggerFactory::class );
		$factory->method( 'newLogger' )
			->willReturnCallback( function ( $title, $user, $vars ) use ( &$varHolder ) {
				$varHolder = $vars;
				$logger = $this->createMock( AbuseLogger::class );
				$logger->method( 'addLogEntries' )
					->willReturn( [ 'local' => [ 1 ], 'global' => [] ] );
				return $logger;
			} );
		$this->setService( AbuseLoggerFactory::SERVICE_NAME, $factory );
	}

	private function assertVariables( array $expected, array $export ) {
		foreach ( $expected as $var => $value ) {
			$this->assertArrayHasKey( $var, $export, "Variable '$var' not set" );

			$actual = $export[$var];
			if ( $var === 'new_html' && is_array( $value ) ) {
				// Special case for new_html: avoid flaky tests, and only check containment
				$this->assertStringContainsString( '<div class="mw-parser-output', $actual );
				$this->assertDoesNotMatchRegularExpression( "/<!--\s*NewPP limit/", $actual );
				$this->assertDoesNotMatchRegularExpression( "/<!--\s*Transclusion/", $actual );
				foreach ( $value as $needle ) {
					$this->assertStringContainsString( $needle, $actual, 'Checking new_html' );
				}
			} else {
				$this->assertSame( $value, $actual, $var );
			}
		}
	}

	public static function provideEditVariables(): Generator {
		$summary = __METHOD__;
		$new = '[https://a.com Test] foo';

		yield 'create page' => [
			'expected' => [
				'action' => 'edit',
				'old_wikitext' => '',
				'old_content_model' => '',
				'new_wikitext' => $new,
				'new_content_model' => 'wikitext',
				'summary' => $summary,
				'new_pst' => $new,
				'new_text' => "Test foo",
				'edit_diff' => "@@ -1,0 +1,1 @@\n+$new\n",
				'edit_diff_pst' => "@@ -1,0 +1,1 @@\n+$new\n",
				'new_size' => strlen( $new ),
				'old_size' => 0,
				'edit_delta' => strlen( $new ),
				'added_lines' => [ $new ],
				'removed_lines' => [],
				'added_lines_pst' => [ $new ],
				'all_links' => [ 'https://a.com/' ],
				'old_links' => [],
				'added_links' => [ 'https://a.com/' ],
				'removed_links' => [],
			],
			'params' => [ 'text' => $new, 'summary' => $summary, 'createonly' => true ],
		];

		// phpcs:disable Generic.Files.LineLength
		$old = '[https://a.com Test] foo';
		$new = "'''Random'''.\nSome ''special'' chars: àèìòù 名探偵コナン.\n[[Help:PST|]] test, [//www.b.com link]";

		yield 'PST and special chars' => [
			'expected' => [
				'action' => 'edit',
				'old_wikitext' => $old,
				'old_content_model' => 'wikitext',
				'new_wikitext' => $new,
				'new_content_model' => 'wikitext',
				'summary' => $summary,
				'new_pst' => "'''Random'''.\nSome ''special'' chars: àèìòù 名探偵コナン.\n[[Help:PST|PST]] test, [//www.b.com link]",
				'new_text' => "Random.\nSome special chars: àèìòù 名探偵コナン.\nPST test, link",
				'edit_diff' => "@@ -1,1 +1,3 @@\n-[https://a.com Test] foo\n+'''Random'''.\n+Some ''special'' chars: àèìòù 名探偵コナン.\n+[[Help:PST|]] test, [//www.b.com link]\n",
				'edit_diff_pst' => "@@ -1,1 +1,3 @@\n-[https://a.com Test] foo\n+'''Random'''.\n+Some ''special'' chars: àèìòù 名探偵コナン.\n+[[Help:PST|PST]] test, [//www.b.com link]\n",
				'new_size' => strlen( $new ),
				'old_size' => strlen( $old ),
				'edit_delta' => strlen( $new ) - strlen( $old ),
				'added_lines' => explode( "\n", $new ),
				'removed_lines' => [ $old ],
				'added_lines_pst' => [ "'''Random'''.", "Some ''special'' chars: àèìòù 名探偵コナン.", '[[Help:PST|PST]] test, [//www.b.com link]' ],
				'old_links' => [ 'https://a.com/' ],
				'all_links' => [ 'https://www.b.com/' ],
				'removed_links' => [ 'https://a.com/' ],
				'added_links' => [ 'https://www.b.com/' ],
			],
			'params' => [ 'text' => $new, 'summary' => $summary ],
			'oldContent' => new WikitextContent( $old ),
		];

		$old = "'''Random'''.\nSome ''special'' chars: àèìòù 名探偵コナン.\n[[Help:PST|PST]] test, [//www.b.com link]";
		$new = '[https://a.com Test] foo';

		yield 'PST and special chars, reverse' => [
			'expected' => [
				'action' => 'edit',
				'old_wikitext' => $old,
				'old_content_model' => 'wikitext',
				'new_wikitext' => $new,
				'new_content_model' => 'wikitext',
				'summary' => $summary,
				'new_html' => [ 'Test</a>' ],
				'new_pst' => '[https://a.com Test] foo',
				'new_text' => 'Test foo',
				'edit_diff' => "@@ -1,3 +1,1 @@\n-'''Random'''.\n-Some ''special'' chars: àèìòù 名探偵コナン.\n-[[Help:PST|PST]] test, [//www.b.com link]\n+[https://a.com Test] foo\n",
				'edit_diff_pst' => "@@ -1,3 +1,1 @@\n-'''Random'''.\n-Some ''special'' chars: àèìòù 名探偵コナン.\n-[[Help:PST|PST]] test, [//www.b.com link]\n+[https://a.com Test] foo\n",
				'new_size' => strlen( $new ),
				'old_size' => strlen( $old ),
				'edit_delta' => strlen( $new ) - strlen( $old ),
				'added_lines' => [ $new ],
				'removed_lines' => explode( "\n", $old ),
				'added_lines_pst' => [ $new ],
				'old_links' => [ 'https://www.b.com/' ],
				'all_links' => [ 'https://a.com/' ],
				'removed_links' => [ 'https://www.b.com/' ],
				'added_links' => [ 'https://a.com/' ],
			],
			'params' => [ 'text' => $new, 'summary' => $summary ],
			'oldContent' => new WikitextContent( $old ),
		];
		// phpcs:enable Generic.Files.LineLength

		$old = 'This edit will be pretty smal';
		$new = $old . 'l';

		yield 'Small edit' => [
			'expected' => [
				'action' => 'edit',
				'old_wikitext' => $old,
				'old_content_model' => 'wikitext',
				'new_wikitext' => $new,
				'new_content_model' => 'wikitext',
				'summary' => $summary,
				'new_html' => [ "<p>This edit will be pretty small\n</p>" ],
				'new_pst' => $new,
				'new_text' => $new,
				'edit_diff' => "@@ -1,1 +1,1 @@\n-$old\n+$new\n",
				'edit_diff_pst' => "@@ -1,1 +1,1 @@\n-$old\n+$new\n",
				'new_size' => strlen( $new ),
				'old_size' => strlen( $old ),
				'edit_delta' => 1,
				'removed_lines' => [ $old ],
				'added_lines' => [ $new ],
				'added_lines_pst' => [ $new ],
				'old_links' => [],
				'all_links' => [],
				'removed_links' => [],
				'added_links' => [],
			],
			'params' => [ 'text' => $new, 'summary' => $summary ],
			'oldContent' => new WikitextContent( $old ),
		];

		yield 'content model change to wikitext' => [
			'expected' => [
				'action' => 'edit',
				'old_wikitext' => "{\n    \"key\": \"value\"\n}",
				'old_content_model' => 'json',
				'new_wikitext' => 'new test https://en.wikipedia.org',
				'new_content_model' => 'wikitext',
				'old_links' => [],
				'all_links' => [ 'https://en.wikipedia.org/' ],
			],
			'params' => [
				'text' => 'new test https://en.wikipedia.org',
				'contentmodel' => 'wikitext',
			],
			'oldContent' => new JsonContent( FormatJson::encode( [ 'key' => 'value' ] ) ),
		];

		yield 'content model change from wikitext' => [
			'expected' => [
				'action' => 'edit',
				'old_wikitext' => 'test https://en.wikipedia.org',
				'old_content_model' => 'wikitext',
				'new_wikitext' => '{"key": "value"}',
				'new_content_model' => 'json',
				'old_links' => [ 'https://en.wikipedia.org/' ],
				'all_links' => [],
			],
			'params' => [
				'text' => '{"key": "value"}',
				'contentmodel' => 'json',
			],
			'oldContent' => new WikitextContent( 'test https://en.wikipedia.org' ),
		];
	}

	/**
	 * @dataProvider provideEditVariables
	 * @covers \MediaWiki\Extension\AbuseFilter\Hooks\Handlers\FilteredActionsHandler
	 * @covers \MediaWiki\Extension\AbuseFilter\VariableGenerator\RunVariableGenerator
	 * @covers \MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGenerator
	 * @covers \MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer
	 */
	public function testEditVariables(
		array $expected, array $params, Content $oldContent = null
	) {
		$varHolder = null;
		$this->prepareServices();
		$this->setAbuseLoggerFactoryWithEavesdrop( $varHolder );

		$title = 'My test page';
		$page = $this->getNonexistingTestPage( $title );
		if ( $oldContent ) {
			$status = $this->editPage( $page, $oldContent, 'Creating test page' );
			$this->assertStatusGood( $status );
		}

		$handler = new FilteredActionsHandler(
			new NullStatsdDataFactory(),
			AbuseFilterServices::getFilterRunnerFactory(),
			AbuseFilterServices::getVariableGeneratorFactory(),
			AbuseFilterServices::getEditRevUpdater(),
			AbuseFilterServices::getBlockedDomainFilter(),
			MediaWikiServices::getInstance()->getPermissionManager()
		);
		$this->setTemporaryHook(
			'EditFilterMergedContent',
			[ $handler, 'onEditFilterMergedContent' ],
			true
		);

		$ex = null;
		try {
			$this->doApiRequestWithToken(
				[ 'action' => 'edit', 'title' => $title ] + $params
			);
		} catch ( ApiUsageException $ex ) {
		}
		$this->assertNotNull( $ex, 'Exception should be thrown' );
		$this->assertNotNull( $varHolder, 'Variables should be set' );
		$export = AbuseFilterServices::getVariablesManager()->dumpAllVars(
			$varHolder,
			array_keys( $expected )
		);
		$this->assertVariables( $expected, $export );
	}

	public static function provideAccountCreationVars(): Generator {
		yield 'create account anonymously' => [
			'expected' => [
				'action' => 'createaccount',
				'accountname' => 'New account',
			]
		];

		yield 'create account by an existing user' => [
			'expected' => [
				'action' => 'createaccount',
				'accountname' => 'New account',
				'user_name' => 'Account creator',
				'user_editcount' => 0,
			],
			'accountName' => 'New account',
			'autocreate' => false,
			'creatorName' => 'Account creator'
		];

		yield 'autocreate an account' => [
			'expected' => [
				'action' => 'autocreateaccount',
				'accountname' => 'New account',
			],
			'accountName' => 'New account',
			'autocreate' => true,
		];
	}

	/**
	 * @dataProvider provideAccountCreationVars
	 * @covers \MediaWiki\Extension\AbuseFilter\AbuseFilterPreAuthenticationProvider
	 * @covers \MediaWiki\Extension\AbuseFilter\Hooks\Handlers\FilteredActionsHandler
	 * @covers \MediaWiki\Extension\AbuseFilter\VariableGenerator\RunVariableGenerator
	 * @covers \MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGenerator
	 */
	public function testAccountCreationVars(
		array $expected,
		string $accountName = 'New account',
		bool $autocreate = false,
		string $creatorName = null
	) {
		$varHolder = null;
		$this->prepareServices();
		$this->setAbuseLoggerFactoryWithEavesdrop( $varHolder );

		$creator = null;
		if ( $creatorName !== null ) {
			$creator = $this->getServiceContainer()->getUserFactory()->newFromName( $creatorName );
			$creator->addToDatabase();
		}
		$status = $this->createAccount( $accountName, $autocreate, $creator );
		$this->assertStatusNotOK( $status );
		$this->assertNotNull( $varHolder, 'Variables should be set' );
		$export = AbuseFilterServices::getVariablesManager()->dumpAllVars(
			$varHolder,
			array_keys( $expected )
		);
		$this->assertVariables( $expected, $export );
	}

}
Watcher/UpdateHitCountWatcherTest.php000066600000003013151334735100013733 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration\Watcher;

use MediaWiki\Extension\AbuseFilter\CentralDBManager;
use MediaWiki\Extension\AbuseFilter\Watcher\UpdateHitCountWatcher;
use MediaWikiIntegrationTestCase;
use Wikimedia\Rdbms\DBConnRef;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LBFactory;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Watcher\UpdateHitCountWatcher
 * @covers ::__construct
 */
class UpdateHitCountWatcherTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers ::run
	 * @covers ::updateHitCounts
	 */
	public function testRun() {
		$localFilters = [ 1, 2, 3 ];
		$globalFilters = [ 4, 5, 6 ];

		$localDB = $this->createMock( DBConnRef::class );
		$localDB->expects( $this->once() )->method( 'update' )->with(
			'abuse_filter',
			[ 'af_hit_count=af_hit_count+1' ],
			[ 'af_id' => $localFilters ]
		);
		$lb = $this->createMock( LBFactory::class );
		$lb->method( 'getPrimaryDatabase' )->willReturn( $localDB );

		$globalDB = $this->createMock( IDatabase::class );
		$globalDB->expects( $this->once() )->method( 'update' )->with(
			'abuse_filter',
			[ 'af_hit_count=af_hit_count+1' ],
			[ 'af_id' => $globalFilters ]
		);
		$centralDBManager = $this->createMock( CentralDBManager::class );
		$centralDBManager->method( 'getConnection' )->willReturn( $globalDB );

		$watcher = new UpdateHitCountWatcher( $lb, $centralDBManager );
		$watcher->run( $localFilters, $globalFilters, 'default' );
		// Two soft assertions done above
		$this->addToAssertionCount( 2 );
	}
}
FilterStoreTest.php000066600000011615151334735100010371 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration;

use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use MediaWiki\Extension\AbuseFilter\Filter\Filter;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\Filter\LastEditInfo;
use MediaWiki\Extension\AbuseFilter\Filter\MutableFilter;
use MediaWiki\Extension\AbuseFilter\Filter\Specs;
use MediaWiki\Extension\AbuseFilter\FilterStore;
use MediaWikiIntegrationTestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Test
 * @group AbuseFilter
 * @group Database
 * @covers \MediaWiki\Extension\AbuseFilter\FilterStore
 */
class FilterStoreTest extends MediaWikiIntegrationTestCase {

	private const DEFAULT_VALUES = [
		'rules' => '/**/',
		'user' => 0,
		'user_text' => 'FilterTester',
		'timestamp' => '20190826000000',
		'enabled' => 1,
		'comments' => '',
		'name' => 'Mock filter',
		'hidden' => 0,
		'hit_count' => 0,
		'throttled' => 0,
		'deleted' => 0,
		'actions' => [],
		'global' => 0,
		'group' => 'default'
	];

	/** @inheritDoc */
	protected $tablesUsed = [
		'abuse_filter',
		'actor',
	];

	/**
	 * @param int $id
	 */
	private function createFilter( int $id ): void {
		$row = self::DEFAULT_VALUES;
		$row['timestamp'] = $this->db->timestamp( $row['timestamp'] );
		$filter = $this->getFilterFromSpecs( [ 'id' => $id ] + $row );
		// Use some black magic to bypass checks
		/** @var FilterStore $filterStore */
		$filterStore = TestingAccessWrapper::newFromObject( AbuseFilterServices::getFilterStore() );
		$this->db->insert(
			'abuse_filter',
			$filterStore->filterToDatabaseRow( $filter ),
			__METHOD__
		);
	}

	/**
	 * @param array $filterSpecs
	 * @param array $actions
	 * @return Filter
	 */
	private function getFilterFromSpecs( array $filterSpecs, array $actions = [] ): Filter {
		$filterSpecs += self::DEFAULT_VALUES;
		return new Filter(
			new Specs(
				$filterSpecs['rules'],
				$filterSpecs['comments'],
				$filterSpecs['name'],
				array_keys( $filterSpecs['actions'] ),
				$filterSpecs['group']
			),
			new Flags(
				$filterSpecs['enabled'],
				$filterSpecs['deleted'],
				$filterSpecs['hidden'],
				$filterSpecs['global']
			),
			$actions,
			new LastEditInfo(
				$filterSpecs['user'],
				$filterSpecs['user_text'],
				$filterSpecs['timestamp']
			),
			$filterSpecs['id'],
			$filterSpecs['hit_count'],
			$filterSpecs['throttled']
		);
	}

	public static function provideSaveFilter_valid(): array {
		return [
			[ SCHEMA_COMPAT_OLD ],
			[ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD ],
			[ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW ],
			[ SCHEMA_COMPAT_NEW ],
		];
	}

	/**
	 * @dataProvider provideSaveFilter_valid
	 */
	public function testSaveFilter_valid( int $stage ) {
		$this->overrideConfigValue( 'AbuseFilterActorTableSchemaMigrationStage', $stage );
		$row = [
			'id' => null,
			'rules' => '/* My rules */',
			'name' => 'Some new filter',
			'enabled' => false,
			'deleted' => true
		];

		$origFilter = MutableFilter::newDefault();
		$newFilter = $this->getFilterFromSpecs( $row );

		$status = AbuseFilterServices::getFilterStore()->saveFilter(
			$this->getTestSysop()->getUser(), $row['id'], $newFilter, $origFilter
		);

		$this->assertStatusGood( $status );
		$value = $status->getValue();
		$this->assertIsArray( $value );
		$this->assertCount( 2, $value );
		$this->assertContainsOnly( 'int', $value );
	}

	public function testSaveFilter_invalid() {
		$this->overrideConfigValue(
			'AbuseFilterActorTableSchemaMigrationStage',
			SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD
		);
		$row = [
			'id' => null,
			'rules' => '1==1',
			'name' => 'Restricted action',
		];
		$actions = [
			'degroup' => []
		];

		// We use restricted actions because that's the last check
		$expectedError = 'abusefilter-edit-restricted';

		$origFilter = MutableFilter::newDefault();
		$newFilter = $this->getFilterFromSpecs( $row, $actions );

		$user = $this->getTestUser()->getUser();
		// Assign -modify and -modify-global, but not -modify-restricted
		$this->overrideUserPermissions( $user, [ 'abusefilter-modify' ] );
		$status = AbuseFilterServices::getFilterStore()->saveFilter( $user, $row['id'], $newFilter, $origFilter );

		$this->assertStatusWarning( $expectedError, $status );
	}

	public function testSaveFilter_noChange() {
		$this->overrideConfigValue(
			'AbuseFilterActorTableSchemaMigrationStage',
			SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD
		);
		$row = [
			'id' => '1',
			'rules' => '/**/',
			'name' => 'Mock filter'
		];

		$filter = $row['id'];
		$this->createFilter( $filter );
		$origFilter = AbuseFilterServices::getFilterLookup()->getFilter( $filter, false );
		$newFilter = $this->getFilterFromSpecs( $row );

		$status = AbuseFilterServices::getFilterStore()->saveFilter(
			$this->getTestSysop()->getUser(), $filter, $newFilter, $origFilter
		);

		$this->assertStatusGood( $status );
		$this->assertFalse( $status->getValue(), 'Status value should be false' );
	}
}
EchoNotifierTest.php000066600000005716151334735100010512 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration;

use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
use MediaWiki\Extension\AbuseFilter\EchoNotifier;
use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\Notifications\Model\Event;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;

/**
 * @group Database
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\EchoNotifier
 */
class EchoNotifierTest extends MediaWikiIntegrationTestCase {

	private const USER_IDS = [
		'1' => 1,
		'2' => 42,
	];

	private function getFilterLookup( int $userID = null ): FilterLookup {
		$lookup = $this->createMock( FilterLookup::class );
		$lookup->method( 'getFilter' )
			->willReturnCallback( function ( $filter, $global ) use ( $userID ) {
				$userID ??= self::USER_IDS[ $global ? "global-$filter" : $filter ] ?? 0;
				$filterObj = $this->createMock( ExistingFilter::class );
				$filterObj->method( 'getUserID' )->willReturn( $userID );
				return $filterObj;
			} );
		return $lookup;
	}

	public static function provideDataForEvent(): array {
		return [
			[ true, 1, 1 ],
			[ true, 2, 42 ],
			[ false, 1, 1 ],
			[ false, 2, 42 ],
		];
	}

	/**
	 * @dataProvider provideDataForEvent
	 * @covers ::__construct
	 * @covers ::getDataForEvent
	 * @covers ::getFilterObject
	 * @covers ::getTitleForFilter
	 */
	public function testGetDataForEvent( bool $loaded, int $filter, int $userID ) {
		$expectedThrottledActions = [];
		$notifier = new EchoNotifier(
			$this->getFilterLookup(),
			$this->createMock( ConsequencesRegistry::class ),
			$loaded
		);
		[
			'type' => $type,
			'title' => $title,
			'extra' => $extra
		] = $notifier->getDataForEvent( $filter );

		$this->assertSame( EchoNotifier::EVENT_TYPE, $type );
		$this->assertInstanceOf( Title::class, $title );
		$this->assertSame( -1, $title->getNamespace() );
		[ , $subpage ] = explode( '/', $title->getText(), 2 );
		$this->assertSame( (string)$filter, $subpage );
		$this->assertSame( [ 'user' => $userID, 'throttled-actions' => $expectedThrottledActions ], $extra );
	}

	/**
	 * @covers ::notifyForFilter
	 */
	public function testNotifyForFilter() {
		$this->markTestSkippedIfExtensionNotLoaded( 'Echo' );
		// Use a real user, or Echo will throw an exception.
		$user = $this->getTestUser()->getUserIdentity();
		$notifier = new EchoNotifier(
			$this->getFilterLookup( $user->getId() ),
			$this->createMock( ConsequencesRegistry::class ),
			true
		);
		$this->assertInstanceOf( Event::class, $notifier->notifyForFilter( 1 ) );
	}

	/**
	 * @covers ::notifyForFilter
	 */
	public function testNotifyForFilter_EchoNotLoaded() {
		$lookup = $this->createMock( FilterLookup::class );
		$lookup->expects( $this->never() )->method( $this->anything() );
		$notifier = new EchoNotifier(
			$lookup,
			$this->createMock( ConsequencesRegistry::class ),
			false
		);
		$this->assertFalse( $notifier->notifyForFilter( 1 ) );
	}

}
FilteredActionsHandlerTest.php000066600000011251151334735100012500 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration;

use Content;
use MediaWiki\Extension\AbuseFilter\BlockedDomainFilter;
use MediaWiki\Extension\AbuseFilter\BlockedDomainStorage;
use MediaWiki\Extension\AbuseFilter\EditRevUpdater;
use MediaWiki\Extension\AbuseFilter\FilterRunner;
use MediaWiki\Extension\AbuseFilter\FilterRunnerFactory;
use MediaWiki\Extension\AbuseFilter\Hooks\Handlers\FilteredActionsHandler;
use MediaWiki\Extension\AbuseFilter\Parser\AFPData;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\RunVariableGenerator;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use Message;
use NullStatsdDataFactory;
use RequestContext;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Hooks\Handlers\FilteredActionsHandler
 * @group Database
 */
class FilteredActionsHandlerTest extends \MediaWikiIntegrationTestCase {

	private array $blockedDomains = [ 'foo.com' => true ];

	/**
	 * @dataProvider provideOnEditFilterMergedContent
	 * @covers ::onEditFilterMergedContent
	 * @covers \MediaWiki\Extension\AbuseFilter\BlockedDomainFilter
	 */
	public function testOnEditFilterMergedContent( $urlsAdded, $expected ) {
		$this->setMwGlobals( 'wgAbuseFilterEnableBlockedExternalDomain', true );

		$filteredActionsHandler = $this->getFilteredActionsHandler( $urlsAdded );
		$context = RequestContext::getMain();
		$context->setTitle( Title::newFromText( 'TestPage' ) );
		$content = $this->createMock( Content::class );
		$user = $this->getTestUser()->getUser();

		$status = Status::newGood();

		$res = $filteredActionsHandler->onEditFilterMergedContent(
			$context,
			$content,
			$status,
			'Edit summary',
			$user,
			false
		);
		$this->assertSame( $expected, $res );
		$this->assertSame( $expected, $status->isOK() );

		if ( !$expected ) {
			// If it's failing, it should report the URL somewhere
			$this->assertStringContainsString(
				'foo.com',
				$status->getErrors()[0]['message']->toString( Message::FORMAT_PLAIN )
			);
		}
	}

	public static function provideOnEditFilterMergedContent() {
		return [
			'subdomain of blocked domain' => [ 'https://bar.foo.com', false ],
			'bare domain with nothing' => [ 'https://foo.com', false ],
			'blocked domain with path' => [ 'https://foo.com/foo/', false ],
			'blocked domain with parameters' => [ 'https://foo.com?foo=bar', false ],
			'blocked domain with path and parameters' => [ 'https://foo.com/foo/?foo=bar', false ],
			'blocked domain with port' => [ 'https://foo.com:9000', false ],
			'blocked domain as uppercase' => [ 'https://FOO.com', false ],
			'unusual protocol' => [ 'ftp://foo.com', false ],
			'mailto is special' => [ 'mailto://user@foo.com', false ],
			'domain not blocked' => [ 'https://foo.bar.com', true ],
			'domain not blocked but it might mistake the subdomain' => [ 'https://foo.com.bar.com', true ],
		];
	}

	private function getFilteredActionsHandler( $urlsAdded ): FilteredActionsHandler {
		$mockRunner = $this->createMock( FilterRunner::class );
		$mockRunner->method( 'run' )
			->willReturn( Status::newGood() );
		$filterRunnerFactory = $this->createMock( FilterRunnerFactory::class );
		$filterRunnerFactory->method( 'newRunner' )
			->willReturn( $mockRunner );

		$vars = new VariableHolder();
		$vars->setVar( 'added_links', AFPData::newFromPHPVar( $urlsAdded ) );

		$variableGenerator = $this->createMock( RunVariableGenerator::class );
		$variableGenerator->method( 'getEditVars' )
			->willReturn( $vars );

		$variableGeneratorFactory = $this->createMock( VariableGeneratorFactory::class );
		$variableGeneratorFactory->method( 'newRunGenerator' )
			 ->willReturn( $variableGenerator );

		$editRevUpdater = $this->createMock( EditRevUpdater::class );

		$variablesManager = $this->createMock( VariablesManager::class );
		$variablesManager->method( 'getVar' )
			->willReturnCallback( fn( $unused, $vars ) => AFPData::newFromPHPVar( $urlsAdded ) );

		$blockedDomainStorage = $this->createMock( BlockedDomainStorage::class );
		$blockedDomainStorage->method( 'loadComputed' )
			->willReturn( $this->blockedDomains );
		$blockedDomainFilter = new BlockedDomainFilter( $variablesManager, $blockedDomainStorage );

		$permissionManager = $this->createMock( PermissionManager::class );
		$permissionManager->method( 'userHasRight' )
			 ->willReturn( false );

		return new FilteredActionsHandler(
			new NullStatsdDataFactory(),
			$filterRunnerFactory,
			$variableGeneratorFactory,
			$editRevUpdater,
			$blockedDomainFilter,
			$permissionManager
		);
	}
}
Parser/ParserEquivsetTest.php000066600000006361151334735100012347 0ustar00<?php
/**
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 *
 * @license GPL-2.0-or-later
 */

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration\Parser;

use EmptyBagOStuff;
use LanguageEn;
use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator;
use MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWikiIntegrationTestCase;
use NullStatsdDataFactory;
use Wikimedia\Equivset\Equivset;

/**
 * Tests that require Equivset, separated from the parser unit tests.
 *
 * @group Test
 * @group AbuseFilter
 * @group AbuseFilterParser
 *
 * @covers \MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator
 * @covers \MediaWiki\Extension\AbuseFilter\Parser\AFPTreeParser
 * @covers \MediaWiki\Extension\AbuseFilter\Parser\AFPTreeNode
 * @covers \MediaWiki\Extension\AbuseFilter\Parser\AFPSyntaxTree
 * @covers \MediaWiki\Extension\AbuseFilter\Parser\AFPParserState
 * @covers \MediaWiki\Extension\AbuseFilter\Parser\AbuseFilterTokenizer
 * @covers \MediaWiki\Extension\AbuseFilter\Parser\AFPToken
 * @covers \MediaWiki\Extension\AbuseFilter\Parser\AFPData
 * @covers \MediaWiki\Extension\AbuseFilter\Parser\SyntaxChecker
 */
class ParserEquivsetTest extends MediaWikiIntegrationTestCase {

	private function getParser(): FilterEvaluator {
		// We're not interested in caching or logging; tests should call respectively setCache
		// and setLogger if they want to test any of those.
		$contLang = new LanguageEn();
		$cache = new EmptyBagOStuff();
		$logger = new \Psr\Log\NullLogger();
		$keywordsManager = AbuseFilterServices::getKeywordsManager();
		$varManager = new VariablesManager(
			$keywordsManager,
			$this->createMock( LazyVariableComputer::class )
		);

		$evaluator = new FilterEvaluator(
			$contLang,
			$cache,
			$logger,
			$keywordsManager,
			$varManager,
			new NullStatsdDataFactory(),
			new Equivset(),
			1000
		);
		$evaluator->toggleConditionLimit( false );
		return $evaluator;
	}

	/**
	 * @param string $rule The rule to parse
	 * @dataProvider provideGenericTests
	 */
	public function testGeneric( $rule ) {
		$this->assertTrue( $this->getParser()->parse( $rule ) );
	}

	public static function provideGenericTests() {
		$testPath = __DIR__ . "/../../../parserTestsEquivset";
		$testFiles = glob( $testPath . "/*.t" );

		foreach ( $testFiles as $testFile ) {
			$testName = basename( substr( $testFile, 0, -2 ) );
			$rule = trim( file_get_contents( $testFile ) );

			yield $testName => [ $rule ];
		}
	}
}
AbuseFilterExtensionJsonTest.php000066600000001260151334735100013056 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration;

use ExtensionRegistry;
use MediaWiki\Tests\ExtensionJsonTestBase;

/**
 * @group Test
 * @group AbuseFilter
 * @coversNothing
 */
class AbuseFilterExtensionJsonTest extends ExtensionJsonTestBase {

	/** @inheritDoc */
	protected string $extensionJsonPath = __DIR__ . '/../../../extension.json';

	public function provideHookHandlerNames(): iterable {
		foreach ( $this->getExtensionJson()['HookHandlers'] ?? [] as $hookHandlerName => $specification ) {
			if ( $hookHandlerName === 'UserMerge' && !ExtensionRegistry::getInstance()->isLoaded( 'UserMerge' ) ) {
				continue;
			}
			yield [ $hookHandlerName ];
		}
	}
}
FilterRunnerTest.php000066600000007734151334735100010555 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration;

use InvalidArgumentException;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesExecutorFactory;
use MediaWiki\Extension\AbuseFilter\EditStashCache;
use MediaWiki\Extension\AbuseFilter\EmergencyCache;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\FilterProfiler;
use MediaWiki\Extension\AbuseFilter\FilterRunner;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;
use Psr\Log\NullLogger;
use User;
use WebRequest;

/**
 * @group Test
 * @group AbuseFilter
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\FilterRunner
 * @covers ::__construct
 */
class FilterRunnerTest extends MediaWikiIntegrationTestCase {
	/**
	 * @param ChangeTagger|null $changeTagger
	 * @param EditStashCache|null $cache
	 * @param VariableHolder|null $vars
	 * @param string $group
	 * @return FilterRunner
	 */
	private function getRunner(
		ChangeTagger $changeTagger = null,
		EditStashCache $cache = null,
		VariableHolder $vars = null,
		$group = 'default'
	): FilterRunner {
		$opts = new ServiceOptions(
			FilterRunner::CONSTRUCTOR_OPTIONS,
			[
				'AbuseFilterValidGroups' => [ 'default' ],
				'AbuseFilterCentralDB' => false,
				'AbuseFilterIsCentral' => false,
				'AbuseFilterConditionLimit' => 1000,
			]
		);
		if ( $cache === null ) {
			$cache = $this->createMock( EditStashCache::class );
			$cache->method( 'seek' )->willReturn( false );
		}
		$request = $this->createMock( WebRequest::class );
		$request->method( 'getIP' )->willReturn( '127.0.0.1' );
		$user = $this->createMock( User::class );
		$user->method( 'getRequest' )->willReturn( $request );
		return new FilterRunner(
			new AbuseFilterHookRunner( $this->createHookContainer() ),
			$this->createMock( FilterProfiler::class ),
			$changeTagger ?? $this->createMock( ChangeTagger::class ),
			$this->createMock( FilterLookup::class ),
			$this->createMock( RuleCheckerFactory::class ),
			$this->createMock( ConsequencesExecutorFactory::class ),
			$this->createMock( AbuseLoggerFactory::class ),
			$this->createMock( VariablesManager::class ),
			$this->createMock( VariableGeneratorFactory::class ),
			$this->createMock( EmergencyCache::class ),
			[],
			$cache,
			new NullLogger(),
			$opts,
			$user,
			$this->createMock( Title::class ),
			$vars ?? VariableHolder::newFromArray( [ 'action' => 'edit' ] ),
			$group
		);
	}

	/**
	 * @covers ::__construct
	 */
	public function testConstructor_invalidGroup() {
		$invalidGroup = 'invalid-group';
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( $invalidGroup );
		$this->getRunner( null, null, new VariableHolder(), $invalidGroup );
	}

	/**
	 * @covers ::__construct
	 */
	public function testConstructor_noAction() {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'variable is not set' );
		$this->getRunner( null, null, new VariableHolder() );
	}

	/**
	 * @covers ::run
	 * @covers ::checkAllFilters
	 */
	public function testConditionsLimit() {
		$cache = $this->createMock( EditStashCache::class );
		$cache->method( 'seek' )->willReturn( [
			'vars' => [],
			'data' => [
				'matches' => [],
				'condCount' => 2000,
				'runtime' => 100.0,
				'profiling' => []
			]
		] );
		$changeTagger = $this->createMock( ChangeTagger::class );
		$changeTagger->expects( $this->once() )->method( 'addConditionsLimitTag' );
		$runner = $this->getRunner( $changeTagger, $cache );
		$this->assertStatusGood( $runner->run() );
	}
}
Hooks/CheckUserHandlerTest.php000066600000007477151334735100012377 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Unit\Hooks;

use MediaWiki\Extension\AbuseFilter\FilterUser;
use MediaWiki\Extension\AbuseFilter\Hooks\Handlers\CheckUserHandler;
use MediaWiki\User\UserIdentityUtils;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Hooks\Handlers\CheckUserHandler
 * @covers ::__construct
 */
class CheckUserHandlerTest extends MediaWikiIntegrationTestCase {

	protected function setUp(): void {
		parent::setUp();
		$this->markTestSkippedIfExtensionNotLoaded( 'CheckUser' );
	}

	private function getCheckUserHandler(): CheckUserHandler {
		$filterUser = $this->createMock( FilterUser::class );
		$filterUser->method( 'getUserIdentity' )
			->willReturn( new UserIdentityValue( 1, 'Abuse filter' ) );
		$userIdentityUtils = $this->createMock( UserIdentityUtils::class );
		$userIdentityUtils->method( 'isNamed' )
			->willReturnCallback( static function ( $name ) {
				return $name !== '*12345';
			} );
		return new CheckUserHandler( $filterUser, $userIdentityUtils );
	}

	private function commonInsertHookAssertions( $shouldChange, $agentField, $ip, $xff, $row ) {
		if ( $shouldChange ) {
			$this->assertSame(
				'127.0.0.1',
				$ip,
				'IP should have changed to 127.0.0.1 because the abuse filter user is making the action.'
			);
			$this->assertFalse(
				$xff,
				'XFF string should have been blanked because the abuse filter user is making the action.'
			);
			$this->assertSame(
				'',
				$row[$agentField],
				'User agent should have been blanked because the abuse filter is making the action.'
			);
		} else {
			$this->assertSame(
				'1.2.3.4',
				$ip,
				'IP should have not been modified by AbuseFilter handling the checkuser insert row hook.'
			);
			$this->assertSame(
				'1.2.3.5',
				$xff,
				'XFF should have not been modified by AbuseFilter handling the checkuser insert row hook.'
			);
			$this->assertArrayNotHasKey(
				$agentField,
				$row,
				'User agent should have not been modified by AbuseFilter handling the checkuser insert row hook.'
			);
		}
	}

	/**
	 * @covers ::onCheckUserInsertChangesRow
	 * @dataProvider provideDataForCheckUserInsertHooks
	 */
	public function testOnCheckUserInsertChangesRow( $user, $shouldChange ) {
		$checkUserHandler = $this->getCheckUserHandler();
		$ip = '1.2.3.4';
		$xff = '1.2.3.5';
		$row = [];
		$checkUserHandler->onCheckUserInsertChangesRow( $ip, $xff, $row, $user, null );
		$this->commonInsertHookAssertions( $shouldChange, 'cuc_agent', $ip, $xff, $row );
	}

	/**
	 * @covers ::onCheckUserInsertPrivateEventRow
	 * @dataProvider provideDataForCheckUserInsertHooks
	 */
	public function testOnCheckUserInsertPrivateEventRow( $user, $shouldChange ) {
		$checkUserHandler = $this->getCheckUserHandler();
		$ip = '1.2.3.4';
		$xff = '1.2.3.5';
		$row = [];
		$checkUserHandler->onCheckUserInsertPrivateEventRow( $ip, $xff, $row, $user, null );
		$this->commonInsertHookAssertions( $shouldChange, 'cupe_agent', $ip, $xff, $row );
	}

	/**
	 * @covers ::onCheckUserInsertLogEventRow
	 * @dataProvider provideDataForCheckUserInsertHooks
	 */
	public function testOnCheckUserInsertLogEventRow( $user, $shouldChange ) {
		$checkUserHandler = $this->getCheckUserHandler();
		$ip = '1.2.3.4';
		$xff = '1.2.3.5';
		$row = [];
		$checkUserHandler->onCheckUserInsertLogEventRow( $ip, $xff, $row, $user, 1, null );
		$this->commonInsertHookAssertions( $shouldChange, 'cule_agent', $ip, $xff, $row );
	}

	public static function provideDataForCheckUserInsertHooks() {
		return [
			'Anonymous user' => [ UserIdentityValue::newAnonymous( '127.0.0.1' ), false ],
			'Temporary user' => [ new UserIdentityValue( 3, '*12345' ), false ],
			'Registered user' => [ new UserIdentityValue( 2, 'Test' ), false ],
			'Abuse filter user' => [ new UserIdentityValue( 1, 'Abuse filter' ), true ],
		];
	}

}
FilterValidatorTest.php000066600000003337151334735100011224 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use MediaWiki\Extension\AbuseFilter\FilterValidator;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWikiIntegrationTestCase;

/**
 * @group Test
 * @group AbuseFilter
 * @group Database
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\FilterValidator
 * @covers ::__construct()
 */
class FilterValidatorTest extends MediaWikiIntegrationTestCase {
	/**
	 * @todo Make this a unit test once static methods in ChangeTags are moved to a service
	 * @param string[] $tags
	 * @param string|null $expected
	 * @covers ::checkAllTags
	 * @dataProvider provideAllTags
	 */
	public function testCheckAllTags( array $tags, ?string $expected ) {
		$validator = new FilterValidator(
			AbuseFilterServices::getChangeTagValidator(),
			$this->createMock( RuleCheckerFactory::class ),
			$this->createMock( AbuseFilterPermissionManager::class ),
			new ServiceOptions(
				FilterValidator::CONSTRUCTOR_OPTIONS,
				[
					'AbuseFilterActionRestrictions' => [],
					'AbuseFilterValidGroups' => [ 'default' ]
				]
			)
		);

		$status = $validator->checkAllTags( $tags );
		$actualError = $status->isGood() ? null : $status->getErrors()[0]['message'];
		$this->assertSame( $expected, $actualError );
	}

	public static function provideAllTags() {
		$invalidTags = [
			'a|b',
			'mw-undo',
			'abusefilter-condition-limit',
			'valid_tag',
		];
		$firstTagError = 'tags-create-invalid-chars';
		yield 'invalid' => [ $invalidTags, $firstTagError ];

		yield 'valid' => [ [ 'fooooobar', 'foooobaz' ], null ];
	}
}
AbuseFilterServicesTest.php000066600000001143151334735100012033 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration;

use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use MediaWiki\Tests\ExtensionServicesTestBase;

/**
 * @group Test
 * @group AbuseFilter
 * @covers \MediaWiki\Extension\AbuseFilter\AbuseFilterServices
 */
class AbuseFilterServicesTest extends ExtensionServicesTestBase {

	/** @inheritDoc */
	protected string $className = AbuseFilterServices::class;

	/** @inheritDoc */
	protected string $serviceNamePrefix = 'AbuseFilter';

	/** @inheritDoc */
	protected array $serviceNamesWithoutMethods = [
		'AbuseFilterRunnerFactory',
	];

}
Api/EvalExpressionTest.php000066600000006503151334735100011607 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration\Api;

use ApiTestCase;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\InternalException;
use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator;
use MediaWiki\Extension\AbuseFilter\Parser\ParserStatus;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Api\EvalExpression
 * @covers ::__construct
 * @group medium
 */
class EvalExpressionTest extends ApiTestCase {
	use AbuseFilterApiTestTrait;
	use MockAuthorityTrait;

	/**
	 * @covers ::execute
	 */
	public function testExecute_noPermissions() {
		$this->expectApiErrorCode( 'permissiondenied' );

		$this->setService( RuleCheckerFactory::SERVICE_NAME, $this->getRuleCheckerFactory() );

		$this->doApiRequest( [
			'action' => 'abusefilterevalexpression',
			'expression' => 'sampleExpression',
		], null, null, $this->mockRegisteredNullAuthority() );
	}

	/**
	 * @covers ::execute
	 * @covers ::evaluateExpression
	 */
	public function testExecute_error() {
		$this->expectApiErrorCode( 'abusefilter-tools-syntax-error' );
		$expression = 'sampleExpression';
		$status = new ParserStatus( $this->createMock( InternalException::class ), [], 1 );
		$ruleChecker = $this->createMock( FilterEvaluator::class );
		$ruleChecker->method( 'checkSyntax' )->with( $expression )
			->willReturn( $status );
		$this->setService( RuleCheckerFactory::SERVICE_NAME, $this->getRuleCheckerFactory( $ruleChecker ) );

		$this->doApiRequest( [
			'action' => 'abusefilterevalexpression',
			'expression' => $expression,
		] );
	}

	/**
	 * @covers ::execute
	 * @covers ::evaluateExpression
	 */
	public function testExecute_Ok() {
		$expression = 'sampleExpression';
		$status = new ParserStatus( null, [], 1 );
		$ruleChecker = $this->createMock( FilterEvaluator::class );
		$ruleChecker->method( 'checkSyntax' )->with( $expression )
			->willReturn( $status );
		$ruleChecker->expects( $this->once() )->method( 'evaluateExpression' )
			->willReturn( 'output' );
		$this->setService( RuleCheckerFactory::SERVICE_NAME, $this->getRuleCheckerFactory( $ruleChecker ) );

		$result = $this->doApiRequest( [
			'action' => 'abusefilterevalexpression',
			'expression' => $expression,
			'prettyprint' => false,
		] );

		$this->assertArrayEquals(
			[
				'abusefilterevalexpression' => [
					'result' => "'output'"
				]
			],
			$result[0],
			false,
			true
		);
	}

	/**
	 * @covers ::execute
	 * @covers ::evaluateExpression
	 */
	public function testExecute_OkAndPrettyPrint() {
		$expression = 'sampleExpression';
		$status = new ParserStatus( null, [], 1 );
		$ruleChecker = $this->createMock( FilterEvaluator::class );
		$ruleChecker->method( 'checkSyntax' )->with( $expression )
			->willReturn( $status );
		$ruleChecker->expects( $this->once() )->method( 'evaluateExpression' )
			->willReturn( [ 'value1', 2 ] );
		$this->setService( RuleCheckerFactory::SERVICE_NAME, $this->getRuleCheckerFactory( $ruleChecker ) );

		$result = $this->doApiRequest( [
			'action' => 'abusefilterevalexpression',
			'expression' => $expression,
			'prettyprint' => true,
		] );

		$this->assertArrayEquals(
			[
				'abusefilterevalexpression' => [
					'result' => "[\n\t0 => 'value1',\n\t1 => 2\n]"
				]
			],
			$result[0],
			false,
			true
		);
	}
}
Api/UnblockAutopromoteTest.php000066600000007224151334735100012475 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration\Api;

use ApiTestCase;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Extension\AbuseFilter\BlockAutopromoteStore;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\User\UserIdentityValue;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Api\UnblockAutopromote
 * @covers ::__construct
 * @group medium
 * @group Database
 */
class UnblockAutopromoteTest extends ApiTestCase {
	use MockAuthorityTrait;

	/**
	 * @covers ::execute
	 */
	public function testExecute_noPermissions() {
		$this->expectApiErrorCode( 'permissiondenied' );

		$store = $this->createMock( BlockAutopromoteStore::class );
		$store->expects( $this->never() )->method( 'unblockAutopromote' );
		$this->setService( BlockAutopromoteStore::SERVICE_NAME, $store );

		$this->doApiRequestWithToken( [
			'action' => 'abusefilterunblockautopromote',
			'user' => 'User'
		], null, $this->mockRegisteredAuthorityWithoutPermissions( [ 'abusefilter-modify' ] ), 'csrf' );
	}

	/**
	 * @covers ::execute
	 */
	public function testExecute_invalidUser() {
		$invalid = 'invalid#username';
		$this->expectApiErrorCode( 'baduser' );

		$store = $this->createMock( BlockAutopromoteStore::class );
		$store->expects( $this->never() )->method( 'unblockAutopromote' );
		$this->setService( BlockAutopromoteStore::SERVICE_NAME, $store );

		$this->doApiRequestWithToken( [
			'action' => 'abusefilterunblockautopromote',
			'user' => $invalid
		], null, $this->mockRegisteredNullAuthority(), 'csrf' );
	}

	/**
	 * @covers ::execute
	 */
	public function testExecute_blocked() {
		$this->expectApiErrorCode( 'blocked' );

		$block = $this->createMock( DatabaseBlock::class );
		$block->method( 'getExpiry' )->willReturn( wfTimestamp( TS_MW, time() + 100000 ) );
		$block->method( 'isSitewide' )->willReturn( true );
		$block->method( 'getReasonComment' )->willReturn( CommentStoreComment::newUnsavedComment( 'test' ) );
		$blockedUser = $this->mockUserAuthorityWithBlock(
			new UserIdentityValue( 42, 'Blocked user' ),
			$block,
			[ 'writeapi', 'abusefilter-modify' ]
		);

		$store = $this->createMock( BlockAutopromoteStore::class );
		$store->expects( $this->never() )->method( 'unblockAutopromote' );
		$this->setService( BlockAutopromoteStore::SERVICE_NAME, $store );

		$this->doApiRequestWithToken( [
			'action' => 'abusefilterunblockautopromote',
			'user' => 'User'
		], null, $blockedUser, 'csrf' );
	}

	/**
	 * @covers ::execute
	 */
	public function testExecute_nothingToDo() {
		$target = 'User';
		$user = $this->mockRegisteredUltimateAuthority();
		$this->expectApiErrorCode( 'notsuspended' );

		$store = $this->createMock( BlockAutopromoteStore::class );
		$store->expects( $this->once() )
			->method( 'unblockAutopromote' )
			->willReturn( false );
		$this->setService( BlockAutopromoteStore::SERVICE_NAME, $store );

		$this->doApiRequestWithToken( [
			'action' => 'abusefilterunblockautopromote',
			'user' => $target
		], null, $user, 'csrf' );
	}

	/**
	 * @covers ::execute
	 */
	public function testExecute_success() {
		$target = 'User';
		$user = $this->mockRegisteredUltimateAuthority();

		$store = $this->createMock( BlockAutopromoteStore::class );
		$store->expects( $this->once() )
			->method( 'unblockAutopromote' )
			->willReturn( true );
		$this->setService( BlockAutopromoteStore::SERVICE_NAME, $store );

		$result = $this->doApiRequestWithToken( [
			'action' => 'abusefilterunblockautopromote',
			'user' => $target
		], null, $user, 'csrf' );

		$this->assertArrayEquals(
			[ 'abusefilterunblockautopromote' => [ 'user' => $target ] ],
			$result[0],
			false,
			true
		);
	}
}
Api/QueryAbuseLogTest.php000066600000000726151334735100011370 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration\Api;

use ApiTestCase;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Api\QueryAbuseLog
 * @group medium
 * @group Database
 * @todo Extend this
 */
class QueryAbuseLogTest extends ApiTestCase {

	/**
	 * @covers ::__construct
	 */
	public function testConstruct() {
		$this->doApiRequest( [
			'action' => 'query',
			'list' => 'abuselog',
		] );
		$this->addToAssertionCount( 1 );
	}

}
Api/CheckMatchTest.php000066600000005454151334735100010636 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration\Api;

use ApiTestCase;
use FormatJson;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\InternalException;
use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator;
use MediaWiki\Extension\AbuseFilter\Parser\ParserStatus;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerStatus;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Api\CheckMatch
 * @covers ::__construct
 * @group medium
 */
class CheckMatchTest extends ApiTestCase {
	use AbuseFilterApiTestTrait;
	use MockAuthorityTrait;

	/**
	 * @covers ::execute
	 */
	public function testExecute_noPermissions() {
		$this->expectApiErrorCode( 'permissiondenied' );

		$this->setService( RuleCheckerFactory::SERVICE_NAME, $this->getRuleCheckerFactory() );

		$this->doApiRequest( [
			'action' => 'abusefiltercheckmatch',
			'filter' => 'sampleFilter',
			'vars' => FormatJson::encode( [] ),
		], null, null, $this->mockRegisteredNullAuthority() );
	}

	public static function provideExecuteOk() {
		return [
			'matched' => [ true ],
			'no match' => [ false ],
		];
	}

	/**
	 * @dataProvider provideExecuteOk
	 * @covers ::execute
	 */
	public function testExecute_Ok( bool $expected ) {
		$filter = 'sampleFilter';
		$checkStatus = new ParserStatus( null, [], 1 );
		$resultStatus = new RuleCheckerStatus( $expected, false, null, [], 1 );
		$ruleChecker = $this->createMock( FilterEvaluator::class );
		$ruleChecker->expects( $this->once() )
			->method( 'checkSyntax' )->with( $filter )
			->willReturn( $checkStatus );
		$ruleChecker->expects( $this->once() )
			->method( 'checkConditions' )->with( $filter )
			->willReturn( $resultStatus );
		$this->setService( RuleCheckerFactory::SERVICE_NAME, $this->getRuleCheckerFactory( $ruleChecker ) );

		$result = $this->doApiRequest( [
			'action' => 'abusefiltercheckmatch',
			'filter' => $filter,
			'vars' => FormatJson::encode( [] ),
		] );

		$this->assertArrayEquals(
			[
				'abusefiltercheckmatch' => [
					'result' => $expected
				]
			],
			$result[0],
			false,
			true
		);
	}

	/**
	 * @covers ::execute
	 */
	public function testExecute_error() {
		$this->expectApiErrorCode( 'badsyntax' );
		$filter = 'sampleFilter';
		$status = new ParserStatus( $this->createMock( InternalException::class ), [], 1 );
		$ruleChecker = $this->createMock( FilterEvaluator::class );
		$ruleChecker->expects( $this->once() )
			->method( 'checkSyntax' )->with( $filter )
			->willReturn( $status );
		$this->setService( RuleCheckerFactory::SERVICE_NAME, $this->getRuleCheckerFactory( $ruleChecker ) );

		$this->doApiRequest( [
			'action' => 'abusefiltercheckmatch',
			'filter' => $filter,
			'vars' => FormatJson::encode( [] ),
		] );
	}

}
Api/CheckSyntaxTest.php000066600000006720151334735100011065 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration\Api;

use ApiTestCase;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleWarning;
use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator;
use MediaWiki\Extension\AbuseFilter\Parser\ParserStatus;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Api\CheckSyntax
 * @covers ::__construct
 * @group medium
 */
class CheckSyntaxTest extends ApiTestCase {
	use AbuseFilterApiTestTrait;
	use MockAuthorityTrait;

	/**
	 * @covers ::execute
	 */
	public function testExecute_noPermissions() {
		$this->expectApiErrorCode( 'permissiondenied' );

		$this->setService( RuleCheckerFactory::SERVICE_NAME, $this->getRuleCheckerFactory() );

		$this->doApiRequest( [
			'action' => 'abusefilterchecksyntax',
			'filter' => 'sampleFilter',
		], null, null, $this->mockRegisteredNullAuthority() );
	}

	/**
	 * @covers ::execute
	 */
	public function testExecute_Ok() {
		$input = 'sampleFilter';
		$status = new ParserStatus( null, [], 1 );
		$ruleChecker = $this->createMock( FilterEvaluator::class );
		$ruleChecker->method( 'checkSyntax' )->with( $input )
			->willReturn( $status );
		$this->setService( RuleCheckerFactory::SERVICE_NAME, $this->getRuleCheckerFactory( $ruleChecker ) );

		$result = $this->doApiRequest( [
			'action' => 'abusefilterchecksyntax',
			'filter' => $input,
		] );

		$this->assertArrayEquals(
			[ 'abusefilterchecksyntax' => [ 'status' => 'ok' ] ],
			$result[0],
			false,
			true
		);
	}

	/**
	 * @covers ::execute
	 */
	public function testExecute_OkAndWarnings() {
		$input = 'sampleFilter';
		$warnings = [
			new UserVisibleWarning( 'exception-1', 3, [] ),
			new UserVisibleWarning( 'exception-2', 8, [ 'param' ] ),
		];
		$status = new ParserStatus( null, $warnings, 1 );
		$ruleChecker = $this->createMock( FilterEvaluator::class );
		$ruleChecker->method( 'checkSyntax' )->with( $input )
			->willReturn( $status );
		$this->setService( RuleCheckerFactory::SERVICE_NAME, $this->getRuleCheckerFactory( $ruleChecker ) );

		$result = $this->doApiRequest( [
			'action' => 'abusefilterchecksyntax',
			'filter' => $input,
		] );

		$this->assertArrayEquals(
			[
				'abusefilterchecksyntax' => [
					'status' => 'ok',
					'warnings' => [
						[
							'message' => '⧼abusefilter-parser-warning-exception-1⧽',
							'character' => 3,
						],
						[
							'message' => '⧼abusefilter-parser-warning-exception-2⧽',
							'character' => 8,
						],
					]
				]
			],
			$result[0],
			false,
			true
		);
	}

	/**
	 * @covers ::execute
	 */
	public function testExecute_error() {
		$input = 'sampleFilter';
		$exception = new UserVisibleException( 'error-id', 4, [] );
		$status = new ParserStatus( $exception, [], 1 );
		$ruleChecker = $this->createMock( FilterEvaluator::class );
		$ruleChecker->method( 'checkSyntax' )->with( $input )
			->willReturn( $status );
		$this->setService( RuleCheckerFactory::SERVICE_NAME, $this->getRuleCheckerFactory( $ruleChecker ) );

		$result = $this->doApiRequest( [
			'action' => 'abusefilterchecksyntax',
			'filter' => $input,
		] );

		$this->assertArrayEquals(
			[
				'abusefilterchecksyntax' => [
					'status' => 'error',
					'message' => '⧼abusefilter-exception-error-id⧽',
					'character' => 4
				]
			],
			$result[0],
			false,
			true
		);
	}
}
Api/AbuseFilterApiTestTrait.php000066600000001445151334735100012503 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration\Api;

use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;

/**
 * This trait contains helper methods for Api integration tests.
 */
trait AbuseFilterApiTestTrait {

	/**
	 * @param FilterEvaluator|null $ruleChecker
	 * @return RuleCheckerFactory
	 */
	protected function getRuleCheckerFactory( FilterEvaluator $ruleChecker = null ): RuleCheckerFactory {
		$factory = $this->createMock( RuleCheckerFactory::class );
		if ( $ruleChecker !== null ) {
			$factory->expects( $this->atLeastOnce() )
				->method( 'newRuleChecker' )
				->willReturn( $ruleChecker );
		} else {
			$factory->expects( $this->never() )->method( 'newRuleChecker' );
		}
		return $factory;
	}
}
Special/SpecialAbuseLogTest.php000066600000011420151334735100012503 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Unit\Special;

use Generator;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Permissions\SimpleAuthority;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\User\UserIdentity;
use MediaWikiIntegrationTestCase;
use stdClass;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog
 */
class SpecialAbuseLogTest extends MediaWikiIntegrationTestCase {
	/**
	 * @param stdClass $row
	 * @param RevisionRecord $revRec
	 * @param bool $canSeeHidden
	 * @param bool $canSeeSuppressed
	 * @param string $expected
	 * @dataProvider provideEntryAndVisibility
	 * @covers ::getEntryVisibilityForUser
	 */
	public function testGetEntryVisibilityForUser(
		stdClass $row,
		RevisionRecord $revRec,
		bool $canSeeHidden,
		bool $canSeeSuppressed,
		string $expected
	) {
		$user = $this->createMock( UserIdentity::class );
		$authority = new SimpleAuthority( $user, $canSeeSuppressed ? [ 'viewsuppressed' ] : [] );
		$afPermManager = $this->createMock( AbuseFilterPermissionManager::class );
		$afPermManager->method( 'canSeeHiddenLogEntries' )->with( $authority )->willReturn( $canSeeHidden );
		$revLookup = $this->createMock( RevisionLookup::class );
		$revLookup->method( 'getRevisionById' )->willReturn( $revRec );
		$this->setService( 'RevisionLookup', $revLookup );
		$this->assertSame(
			$expected,
			SpecialAbuseLog::getEntryVisibilityForUser( $row, $authority, $afPermManager )
		);
	}

	public static function provideEntryAndVisibility(): Generator {
		$visibleRow = (object)[ 'afl_rev_id' => 1, 'afl_deleted' => 0 ];
		$hiddenRow = (object)[ 'afl_rev_id' => 1, 'afl_deleted' => 1 ];
		$page = new PageIdentityValue( 1, NS_MAIN, 'Foo', PageIdentityValue::LOCAL );
		$visibleRev = new MutableRevisionRecord( $page );

		yield 'Visible entry and rev, cannot see hidden, cannot see suppressed' =>
			[ $visibleRow, $visibleRev, false, false, SpecialAbuseLog::VISIBILITY_VISIBLE ];
		yield 'Visible entry and rev, can see hidden, cannot see suppressed' =>
			[ $visibleRow, $visibleRev, true, false, SpecialAbuseLog::VISIBILITY_VISIBLE ];
		yield 'Visible entry and rev, cannot see hidden, can see suppressed' =>
			[ $visibleRow, $visibleRev, false, false, SpecialAbuseLog::VISIBILITY_VISIBLE ];
		yield 'Visible entry and rev, can see hidden, can see suppressed' =>
			[ $visibleRow, $visibleRev, true, false, SpecialAbuseLog::VISIBILITY_VISIBLE ];

		yield 'Hidden entry, visible rev, can see hidden, cannot see suppressed' =>
			[ $hiddenRow, $visibleRev, true, false, SpecialAbuseLog::VISIBILITY_VISIBLE ];
		yield 'Hidden entry, visible rev, cannot see hidden, cannot see suppressed' =>
			[ $hiddenRow, $visibleRev, false, false, SpecialAbuseLog::VISIBILITY_HIDDEN ];
		yield 'Hidden entry, visible rev, can see hidden, can see suppressed' =>
			[ $hiddenRow, $visibleRev, true, true, SpecialAbuseLog::VISIBILITY_VISIBLE ];
		yield 'Hidden entry, visible rev, cannot see hidden, can see suppressed' =>
			[ $hiddenRow, $visibleRev, false, true, SpecialAbuseLog::VISIBILITY_HIDDEN ];

		$userSupRev = new MutableRevisionRecord( $page );
		$userSupRev->setVisibility( RevisionRecord::SUPPRESSED_USER );
		yield 'Hidden entry, user suppressed rev, can see hidden, cannot see suppressed' =>
			[ $hiddenRow, $userSupRev, true, false, SpecialAbuseLog::VISIBILITY_HIDDEN_IMPLICIT ];
		yield 'Hidden entry, user suppressed rev, cannot see hidden, cannot see suppressed' =>
			[ $hiddenRow, $userSupRev, false, false, SpecialAbuseLog::VISIBILITY_HIDDEN ];
		yield 'Hidden entry, user suppressed rev, can see hidden, can see suppressed' =>
			[ $hiddenRow, $userSupRev, true, true, SpecialAbuseLog::VISIBILITY_VISIBLE ];
		yield 'Hidden entry, user suppressed rev, cannot see hidden, can see suppressed' =>
			[ $hiddenRow, $userSupRev, false, true, SpecialAbuseLog::VISIBILITY_HIDDEN ];

		$allSuppRev = new MutableRevisionRecord( $page );
		$allSuppRev->setVisibility( RevisionRecord::SUPPRESSED_ALL );
		yield 'Hidden entry, all suppressed rev, can see hidden, cannot see suppressed' =>
			[ $hiddenRow, $allSuppRev, true, false, SpecialAbuseLog::VISIBILITY_HIDDEN_IMPLICIT ];
		yield 'Hidden entry, all suppressed rev, cannot see hidden, cannot see suppressed' =>
			[ $hiddenRow, $allSuppRev, false, false, SpecialAbuseLog::VISIBILITY_HIDDEN ];
		yield 'Hidden entry, all suppressed rev, can see hidden, can see suppressed' =>
			[ $hiddenRow, $allSuppRev, true, true, SpecialAbuseLog::VISIBILITY_VISIBLE ];
		yield 'Hidden entry, all suppressed rev, cannot see hidden, can see suppressed' =>
			[ $hiddenRow, $allSuppRev, false, true, SpecialAbuseLog::VISIBILITY_HIDDEN ];
	}
}
Special/SpecialAbuseFilterTest.php000066600000005752151334735100013222 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Integration\Special;

use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseFilter;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewDiff;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewEdit;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewExamine;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewHistory;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewImport;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewList;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewRevert;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewTestBatch;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewTools;
use MediaWiki\MediaWikiServices;
use SpecialPageTestBase;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseFilter
 */
class SpecialAbuseFilterTest extends SpecialPageTestBase {

	/**
	 * @covers ::instantiateView
	 * @covers ::__construct
	 * @covers \MediaWiki\Extension\AbuseFilter\Special\AbuseFilterSpecialPage::__construct
	 * @covers \MediaWiki\Extension\AbuseFilter\View\AbuseFilterView::__construct
	 * @covers \MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewDiff::__construct
	 * @covers \MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewEdit::__construct
	 * @covers \MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewExamine::__construct
	 * @covers \MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewHistory::__construct
	 * @covers \MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewImport::__construct
	 * @covers \MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewList::__construct
	 * @covers \MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewRevert::__construct
	 * @covers \MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewTestBatch::__construct
	 * @covers \MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewTools::__construct
	 * @dataProvider provideInstantiateView
	 */
	public function testInstantiateView( string $viewClass, array $params = [] ) {
		$sp = $this->newSpecialPage();
		$view = $sp->instantiateView( $viewClass, $params );
		$this->assertInstanceOf( $viewClass, $view );
	}

	public static function provideInstantiateView(): array {
		return [
			[ AbuseFilterViewDiff::class ],
			[ AbuseFilterViewEdit::class, [ 'filter' => 1 ] ],
			[ AbuseFilterViewExamine::class ],
			[ AbuseFilterViewHistory::class ],
			[ AbuseFilterViewImport::class ],
			[ AbuseFilterViewList::class ],
			[ AbuseFilterViewRevert::class ],
			[ AbuseFilterViewTestBatch::class ],
			[ AbuseFilterViewTools::class ],
		];
	}

	/**
	 * @inheritDoc
	 */
	protected function newSpecialPage(): SpecialAbuseFilter {
		$services = MediaWikiServices::getInstance();
		$sp = new SpecialAbuseFilter(
			$services->getService( AbuseFilterPermissionManager::SERVICE_NAME ),
			$services->getObjectFactory()
		);
		$sp->setLinkRenderer(
			$services->getLinkRendererFactory()->create()
		);
		return $sp;
	}

}
includes/Rest/Handler/PageRedirectHandlerTest.php000066600000012724151335110110016052 0ustar00<?php

namespace MediaWiki\Tests\Rest\Handler;

use HashBagOStuff;
use InvalidArgumentException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Rest\RequestInterface;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Rest\Handler\PageSourceHandler
 * @covers \MediaWiki\Rest\Handler\PageHTMLHandler
 * @covers \MediaWiki\Rest\Handler\Helper\PageRedirectHelper
 * @group Database
 */
class PageRedirectHandlerTest extends MediaWikiIntegrationTestCase {
	use PageHandlerTestTrait;
	use HandlerTestTrait;
	use HTMLHandlerTestTrait;

	private const WIKITEXT = 'Hello \'\'\'World\'\'\'';

	private const HTML = '<p>Hello <b>World</b></p>';

	/** @var HashBagOStuff */
	private $parserCacheBagOStuff;

	protected function setUp(): void {
		parent::setUp();

		// Clean up these tables after each test
		$this->tablesUsed = [
			'page',
			'revision',
			'comment',
			'text',
			'content'
		];

		$this->parserCacheBagOStuff = new HashBagOStuff();
	}

	private function getHandler( $name, RequestInterface $request ) {
		switch ( $name ) {
			case 'source':
				return $this->newPageSourceHandler();
			case 'bare':
				return $this->newPageSourceHandler();
			case 'html':
				return $this->newPageHtmlHandler( $request );
			case 'with_html':
				return $this->newPageHtmlHandler( $request );
			case 'history':
				return $this->newPageHistoryHandler();
			case 'history_count':
				return $this->newPageHistoryCountHandler();
			case 'links_language':
				return $this->newLanguageLinksHandler();
			default:
				throw new InvalidArgumentException( "Unknown handler: $name" );
		}
	}

	/**
	 * @dataProvider temporaryRedirectProvider
	 */
	public function testTemporaryRedirect(
		$format, $path, $queryParams, $expectedStatus, $hasBodyRedirectTarget = true
	) {
		$targetPageTitle = 'PageEndpointTestPage';
		$redirectPageTitle = 'RedirectPage';
		$this->getExistingTestPage( $targetPageTitle );
		$status = $this->editPage( $redirectPageTitle, "#REDIRECT [[$targetPageTitle]]" );
		$this->assertStatusOK( $status );

		$request = new RequestData(
			[
				'pathParams' => [ 'title' => $redirectPageTitle ],
				'queryParams' => $queryParams
			]
		);
		$handler = $this->getHandler( $format, $request );
		$response = $this->executeHandler( $handler, $request, [
			'format' => $format,
			'path' => $path,
		] );
		$headerLocation = $response->getHeaderLine( 'location' );

		$this->assertEquals( $expectedStatus, $response->getStatusCode() );
		if ( $hasBodyRedirectTarget && $expectedStatus === 200 ) {
			$body = json_decode( $response->getBody()->getContents() );
			$this->assertStringContainsString( $targetPageTitle, $body->redirect_target );
			$this->assertUrlQueryParameters( $body->redirect_target, $queryParams );
		}
		if ( $expectedStatus !== 200 ) {
			$this->assertStringContainsString( $targetPageTitle, $headerLocation );
			if ( $headerLocation ) {
				$this->assertUrlQueryParameters( $headerLocation, $queryParams );
			}
		}
	}

	public function temporaryRedirectProvider() {
		yield [
			'source',
			'/page/{title}',
			[],
			200
		];

		yield [
			'bare',
			'/page/{title}/bare',
			[],
			200
		];

		yield [
			'html',
			'/page/{title}/html',
			[],
			307,
			false
		];

		yield [
			'html',
			'/page/{title}/html',
			[ 'flavor' => 'edit', 'dummy' => 'test' ],
			307,
			false
		];

		yield [
			'html',
			'/page/{title}/html',
			[ 'redirect' => 'no' ],
			200,
			false
		];

		yield [
			'with_html',
			'/page/{title}/with_html',
			[],
			307,
		];

		yield [
			'with_html',
			'/page/{title}/with_html',
			[ 'flavor' => 'edit', 'dummy' => 'test', 'redirect' => 'no' ],
			200
		];
	}

	/**
	 * @dataProvider permanentRedirectProvider
	 */
	public function testPermanentRedirect( $format, $path, $extraPathParams = [], $queryParams = [] ) {
		$page = $this->getExistingTestPage( 'SourceEndpointTestPage with spaces' );
		$this->assertStatusGood( $this->editPage( $page, self::WIKITEXT ),
			'Edited a page'
		);

		$pathParams = [ 'title' => $page->getTitle()->getPrefixedText() ] + $extraPathParams;
		$request = new RequestData(
			[
				'pathParams' => $pathParams,
				'queryParams' => $queryParams
			]
		);

		$handler = $this->getHandler( $format, $request );
		$response = $this->executeHandler( $handler, $request, [
			'format' => $format,
			'path' => $path
		] );
		$headerLocation = $response->getHeaderLine( 'location' );
		$this->assertEquals( 301, $response->getStatusCode() );
		$this->assertStringContainsString( $page->getTitle()->getPrefixedDBkey(), $headerLocation );
		$this->assertUrlQueryParameters( $headerLocation, $queryParams );
	}

	public static function permanentRedirectProvider() {
		yield [ 'source', '/page/{title}', [], [ 'flavor' => 'edit', 'dummy' => 'test' ] ];
		yield [ 'bare', '/page/{title}/bare' ];
		yield [ 'html', '/page/{title}/html' ];
		yield [ 'with_html', '/page/{title}/with_html' ];
		yield [ 'history', '/page/{title}/history' ];
		yield [ 'history_count', '/page/{title}/history/counts/{type}', [ 'type' => 'edits' ] ];
		yield [ 'links_language', '/page/{title}/links/language' ];
	}

	/**
	 * @param string $url
	 * @param array $queryParams
	 * @return void
	 */
	private function assertUrlQueryParameters( string $url, array $queryParams ): void {
		$parsedUrl = $this->getServiceContainer()->getUrlUtils()->parse( $url );
		$urlParameters = [];

		if ( is_array( $parsedUrl ) ) {
			if ( array_key_exists( 'query', $parsedUrl ) ) {
				$urlParameters = wfCgiToArray(
					$parsedUrl['query']
				);
			}
		}
		$this->assertArrayEquals( $queryParams, $urlParameters );
	}
}
includes/Rest/Handler/Helper/PageRedirectHelperTest.php000066600000013560151335110110017132 0ustar00<?php

namespace MediaWiki\Tests\Rest\Handler\Helper;

use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Page\RedirectStore;
use MediaWiki\Rest\Handler\Helper\PageRedirectHelper;
use MediaWiki\Rest\RequestData;
use MediaWiki\Rest\ResponseFactory;
use MediaWiki\Tests\Rest\Handler\PageHandlerTestTrait;
use MediaWikiIntegrationTestCase;
use Title;

/**
 * @covers \MediaWiki\Rest\Handler\Helper\PageRedirectHelper
 * @group Database
 */
class PageRedirectHelperTest extends MediaWikiIntegrationTestCase {
	use PageHandlerTestTrait;

	private function newRedirectHelper( $queryParams = [] ) {
		$baseUrl = 'https://example.test/api';

		$services = $this->getServiceContainer();

		$redirectStore = $this->createNoOpMock( RedirectStore::class, [ 'getRedirectTarget' ] );
		$redirectStore->method( 'getRedirectTarget' )
			->willReturnCallback( static function ( PageIdentity $page ) use ( $services ) {
				if ( str_starts_with( $page->getDBkey(), 'Redirect_to_' ) ) {
					$titleParser = $services->getTitleParser();
					return $titleParser->parseTitle( substr( $page->getDBkey(), 12 ), $page->getNamespace() );
				}

				return null;
			} );

		$responseFactory = new ResponseFactory( [] );

		$router = $this->newRouter( $baseUrl );
		$request = new RequestData( [ 'queryParams' => $queryParams ] );

		return new PageRedirectHelper(
			$redirectStore,
			$services->getTitleFormatter(),
			$responseFactory,
			$router,
			'/test/{title}',
			$request,
			$services->getLanguageConverterFactory()
		);
	}

	public static function provideGetTargetUrl() {
		yield [ 'Föö+Bar', null, 'https://example.test/api/test/F%C3%B6%C3%B6%2BBar' ];

		yield [ 'Föö+Bar', [ 'a' => 1 ], 'https://example.test/api/test/F%C3%B6%C3%B6%2BBar?a=1' ];

		$page = PageReferenceValue::localReference( NS_TALK, 'Q/A' );
		yield [ $page, null, 'https://example.test/api/test/Talk%3AQ%2FA' ];
	}

	/**
	 * @dataProvider provideGetTargetUrl
	 */
	public function testGetTargetUrl( $title, $queryParams, $expectedUrl ) {
		$helper = $this->newRedirectHelper( $queryParams ?: [] );
		$this->assertSame( $expectedUrl, $helper->getTargetUrl( $title ) );
	}

	public static function provideNormalizationRedirect() {
		$page = new PageIdentityValue( 7, NS_MAIN, 'Foo', false );
		yield [ $page, 'foo', 'https://example.test/api/test/Foo' ];

		$page = new PageIdentityValue( 7, NS_MAIN, 'Foo', false );
		yield [ $page, 'Foo', null ];

		$page = new PageIdentityValue( 7, NS_TALK, 'Foo_bar/baz', false );
		yield [ $page, 'Talk:Foo bar/baz', 'https://example.test/api/test/Talk%3AFoo_bar%2Fbaz' ];

		$page = new PageIdentityValue( 7, NS_TALK, 'Foo_bar/baz', false );
		yield [ $page, 'Talk:Foo_bar/baz', null ];
	}

	/**
	 * @dataProvider provideNormalizationRedirect
	 */
	public function testNormalizationRedirect(
		PageIdentity $page,
		string $title,
		?string $expectedUrl
	) {
		$helper = $this->newRedirectHelper();

		$resp = $helper->createNormalizationRedirectResponseIfNeeded( $page, $title );

		if ( $expectedUrl === null ) {
			$this->assertNull( $resp );
		} else {
			$this->assertNotNull( $resp );
			$this->assertSame( $expectedUrl, $resp->getHeaderLine( 'Location' ) );
			$this->assertSame( 301, $resp->getStatusCode() );
		}
	}

	public static function provideWikiRedirect() {
		$page = new PageIdentityValue( 7, NS_MAIN, 'Redirect_to_foo', false );
		yield [ $page, 'https://example.test/api/test/Foo' ];

		$page = new PageIdentityValue( 7, NS_MAIN, 'foo', false );
		yield [ $page, null ];
	}

	/**
	 * @dataProvider provideWikiRedirect
	 */
	public function testWikiRedirect(
		PageIdentity $page,
		?string $expectedUrl
	) {
		$helper = $this->newRedirectHelper();
		$helper->setFollowWikiRedirects( true );

		$target = $helper->getWikiRedirectTargetUrl( $page );
		$resp1 = $helper->createWikiRedirectResponseIfNeeded( $page );
		$resp2 = $helper->createRedirectResponseIfNeeded( $page, $page->getDBkey() );

		if ( $expectedUrl === null ) {
			$this->assertNull( $target );
			$this->assertNull( $resp1 );
			$this->assertNull( $resp2 );
		} else {
			$this->assertSame( $expectedUrl, $target );

			$this->assertNotNull( $resp1 );
			$this->assertSame( $expectedUrl, $resp1->getHeaderLine( 'Location' ) );
			$this->assertSame( 307, $resp1->getStatusCode() );

			$this->assertNotNull( $resp2 );
			$this->assertSame( $expectedUrl, $resp2->getHeaderLine( 'Location' ) );
			$this->assertSame( 307, $resp2->getStatusCode() );
		}
	}

	public function testVariantRedirect() {
		$page = $this->getNonexistingTestPage( 'TestPage' );
		// NOTE: "TestPage" variant to en-x-piglatin is "EsttayAgepay"
		$this->insertPage( Title::newFromText( 'EsttayAgepay' ) );

		$helper = $this->newRedirectHelper();
		$helper->setFollowWikiRedirects( true );

		$resp = $helper->createRedirectResponseIfNeeded( $page, $page->getDBkey() );

		$this->assertNotNull( $resp );
		$this->assertSame(
			'https://example.test/api/test/EsttayAgepay',
			$resp->getHeaderLine( 'Location' )
		);
		$this->assertSame( 307, $resp->getStatusCode() );
	}

	public function testWikiRedirectDisabled() {
		$page = new PageIdentityValue( 7, NS_MAIN, 'Redirect_to_foo', false );

		// We assume that wiki redirect handling is disabled per default.
		$helper = $this->newRedirectHelper();

		$target = $helper->getWikiRedirectTargetUrl( $page );
		$this->assertNotNull( $target, 'getWikiRedirectTargetUrl() should not be disabled' );

		$resp = $helper->createWikiRedirectResponseIfNeeded( $page );
		$this->assertNotNull( $resp, 'createWikiRedirectResponseIfNeeded() should not be disabled' );

		$resp = $helper->createRedirectResponseIfNeeded( $page, null );
		$this->assertNull( $resp, 'createRedirectResponseIfNeeded() should not follow wiki redirect' );

		$resp = $helper->createRedirectResponseIfNeeded( $page, 'redirect to foo' );
		$this->assertNotNull( $resp, 'createRedirectResponseIfNeeded() should still follow normalization redirect' );
	}

}
includes/editpage/Constraint/ChangeTagsConstraintTest.php000066600000004312151335110110017675 0ustar00<?php
/**
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 */

use MediaWiki\EditPage\Constraint\ChangeTagsConstraint;
use MediaWiki\EditPage\Constraint\IEditConstraint;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;

/**
 * Tests the ChangeTagsConstraint
 *
 * @author DannyS712
 *
 * @covers \MediaWiki\EditPage\Constraint\ChangeTagsConstraint
 * @group Database
 */
class ChangeTagsConstraintTest extends MediaWikiIntegrationTestCase {
	use EditConstraintTestTrait;
	use MockAuthorityTrait;

	protected function setUp(): void {
		parent::setUp();
		$this->tablesUsed = array_merge(
			$this->tablesUsed,
			[ 'change_tag', 'change_tag_def', 'logging' ]
		);
	}

	public function testPass() {
		$tagName = 'tag-for-constraint-test-pass';
		ChangeTags::defineTag( $tagName );

		$constraint = new ChangeTagsConstraint(
			$this->mockRegisteredUltimateAuthority(),
			[ $tagName ]
		);
		$this->assertConstraintPassed( $constraint );
	}

	public function testNoTags() {
		// Early return for no tags being added
		$constraint = new ChangeTagsConstraint(
			$this->mockRegisteredUltimateAuthority(),
			[]
		);
		$this->assertConstraintPassed( $constraint );
	}

	public function testFailure() {
		$tagName = 'tag-for-constraint-test-fail';
		ChangeTags::defineTag( $tagName );

		$constraint = new ChangeTagsConstraint(
			$this->mockRegisteredAuthorityWithoutPermissions( [ 'applychangetags' ] ),
			[ $tagName ]
		);
		$this->assertConstraintFailed(
			$constraint,
			IEditConstraint::AS_CHANGE_TAG_ERROR
		);
	}

}
includes/editpage/Constraint/EditFilterMergedContentHookConstraintTest.php000066600000007263151335110110023234 0ustar00<?php
/**
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 */

use MediaWiki\EditPage\Constraint\EditFilterMergedContentHookConstraint;
use MediaWiki\EditPage\Constraint\IEditConstraint;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Status\Status;
use MediaWiki\User\User;
use Wikimedia\TestingAccessWrapper;

/**
 * Tests the EditFilterMergedContentHookConstraint
 *
 * @author DannyS712
 *
 * @covers \MediaWiki\EditPage\Constraint\EditFilterMergedContentHookConstraint
 * @todo Make this a unit test when Message will no longer use the global state.
 */
class EditFilterMergedContentHookConstraintTest extends MediaWikiIntegrationTestCase {
	use EditConstraintTestTrait;

	private function getConstraint( $hookResult ) {
		$hookContainer = $this->createMock( HookContainer::class );
		$hookContainer->expects( $this->once() )
			->method( 'run' )
			->with(
				'EditFilterMergedContent',
				$this->anything() // Not worrying about the hook call here
			)
			->willReturn( $hookResult );
		$language = $this->createMock( Language::class );
		$language->method( 'getCode' )
			->willReturn( 'en' );
		$constraint = new EditFilterMergedContentHookConstraint(
			$hookContainer,
			$this->getMockForAbstractClass( Content::class ),
			$this->createMock( RequestContext::class ),
			'EditSummaryGoesHere',
			true, // Minor edit
			$language,
			$this->createMock( User::class )
		);
		return $constraint;
	}

	public function testPass() {
		$constraint = $this->getConstraint( true );
		$this->assertConstraintPassed( $constraint );
		$this->assertSame( '', $constraint->getHookError() );
	}

	public function testFailure_goodStatus() {
		// Code path 1: Hook returns false, but status is still good
		// Status has no value set, falls back to AS_HOOK_ERROR_EXPECTED
		$constraint = $this->getConstraint( false );
		$this->assertConstraintFailed( $constraint, IEditConstraint::AS_HOOK_ERROR_EXPECTED );
	}

	public function testFailure_badStatus() {
		// Code path 2: Hook returns false, status is bad
		// To avoid using the real Status::getWikiText, which can use global state, etc.,
		// replace the status object with a mock
		$constraint = $this->getConstraint( false );
		$mockStatus = $this->getMockBuilder( Status::class )
			->onlyMethods( [ 'isGood', 'getWikiText' ] )
			->getMock();
		$mockStatus->method( 'isGood' )->willReturn( false );
		$mockStatus->method( 'getWikiText' )->willReturn( 'WIKITEXT' );
		$mockStatus->value = 12345;
		TestingAccessWrapper::newFromObject( $constraint )->status = $mockStatus;

		$this->assertConstraintFailed(
			$constraint,
			12345 // Value is set in hook (or in this case in the mock)
		);
	}

	public function testFailure_notOKStatus() {
		// Code path 3: Hook returns true, but status is not okay
		$constraint = $this->getConstraint( true );
		$status = Status::newGood();
		$status->setOK( false );
		TestingAccessWrapper::newFromObject( $constraint )->status = $status;

		$this->assertConstraintFailed(
			$constraint,
			IEditConstraint::AS_HOOK_ERROR_EXPECTED
		);
	}

}
includes/Storage/RevertedTagUpdateIntegrationTest.php000066600000025003151335110110017105 0ustar00<?php

namespace MediaWiki\Tests\Storage;

use ChangeTags;
use DeferredUpdates;
use FormatJson;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWikiIntegrationTestCase;
use RecentChange;
use WikiPage;

/**
 * @covers \MediaWiki\Storage\RevertedTagUpdate
 * @covers \RevertedTagUpdateJob
 * @covers \MediaWiki\Storage\RevertedTagUpdateManager
 *
 * @group Database
 * @group medium
 * @see RevertedTagUpdateTest for non-DB tests
 */
class RevertedTagUpdateIntegrationTest extends MediaWikiIntegrationTestCase {
	protected function setUp(): void {
		parent::setUp();

		$this->tablesUsed = array_merge(
			$this->tablesUsed,
			[
				'page',
				'revision',
				'comment',
				'text',
				'content',
				'change_tag',
				'objectcache',
				'job'
			]
		);
	}

	/**
	 * This test ensures the update is not performed at the end of the web request, but
	 * enqueued as a job for later execution instead.
	 *
	 * The reverting user here has autopatrol rights, so the update should be enqueued
	 * immediately.
	 */
	public function testWithJobQueue() {
		$num = 5;

		$page = $this->getExistingTestPage();
		$revisionIds = $this->setupEditsOnPage( $page, $num );

		// Make a manual revert to revision with content '0'
		// The user HAS the 'autopatrol' right
		$revertRevId = $this->editPage(
			$page,
			'0',
			'',
			NS_MAIN,
			$this->getTestSysop()->getUser()
		)->value['revision-record']->getId();
		$revertedRevs = array_slice( $revisionIds, 1 );

		DeferredUpdates::doUpdates();

		// the tags should not have been populated yet
		$this->verifyNoRevertedTags( $revertedRevs );

		// run the job
		$this->runJobs( [], [
			'type' => 'revertedTagUpdate'
		] );

		// now the tags should be populated
		$this->verifyRevertedTags( $revertedRevs, $revertRevId );
	}

	/**
	 * In this scenario, only the patrol mechanism is used for delaying the execution of
	 * the RevertedTagUpdate.
	 */
	public function testDelayedJobExecutionWithPatrol() {
		$num = 5;

		$page = $this->getExistingTestPage();
		$revisionIds = $this->setupEditsOnPage( $page, $num );

		// Make a manual revert to revision with content '0'
		// The user DOES NOT have the 'autopatrol' right
		$revertRevId = $this->editPage(
			$page,
			'0',
			'',
			NS_MAIN,
			$this->getTestUser()->getUser()
		)->value['revision-record']->getId();
		$revertedRevs = array_slice( $revisionIds, 1 );

		DeferredUpdates::doUpdates();

		// the tags should not have been populated yet
		$this->verifyNoRevertedTags( $revertedRevs );

		// try to run the job
		$this->runJobs( [ 'numJobs' => 0 ], [
			'type' => 'revertedTagUpdate'
		] );

		// the tags still should not be present as the edit is pending approval
		$this->verifyNoRevertedTags( $revertedRevs );

		// approve the edit – this should enqueue the job
		$rc = RecentChange::newFromConds( [ 'rc_this_oldid' => $revertRevId ] );
		$rc->reallyMarkPatrolled();

		// run the job
		$this->runJobs( [ 'numJobs' => 1 ], [
			'type' => 'revertedTagUpdate'
		] );

		// now the tags should be populated
		$this->verifyRevertedTags( $revertedRevs, $revertRevId );
	}

	/**
	 * Ensure the update is not performed even after the edit is approved if it
	 * was reverted in the meantime.
	 */
	public function testNoJobExecutionWhenRevertIsReverted() {
		$num = 5;

		$page = $this->getExistingTestPage();
		$revisionIds = $this->setupEditsOnPage( $page, $num );

		// Make a manual revert to revision with content '0'
		// The user DOES NOT have the 'autopatrol' right
		$revertId1 = $this->editPage(
			$page,
			'0',
			'',
			NS_MAIN,
			$this->getTestUser()->getUser()
		)->value['revision-record']->getId();
		$revertedRevs = array_slice( $revisionIds, 1 );

		DeferredUpdates::doUpdates();
		$this->runJobs( [ 'numJobs' => 0 ], [
			'type' => 'revertedTagUpdate'
		] );

		// the tags should not be present as the edit is pending approval
		$this->verifyNoRevertedTags( $revertedRevs );

		// now a sysop reverts the revert made by a regular user
		$revertId2 = $this->editPage(
			$page,
			'5',
			'',
			NS_MAIN,
			$this->getTestSysop()->getUser()
		)->value['revision-record']->getId();
		DeferredUpdates::doUpdates();
		$this->runJobs( [], [
			'type' => 'revertedTagUpdate'
		] );
		$this->verifyRevertedTags( [ $revertId1 ], $revertId2 );

		// approve the edit – this should enqueue the job
		$rc = RecentChange::newFromConds( [ 'rc_this_oldid' => $revertId1 ] );
		$rc->reallyMarkPatrolled();

		// Run the job.
		// The job should notice that the revert is reverted and refuse to perform
		// the update.
		$this->runJobs( [], [
			'type' => 'revertedTagUpdate'
		] );

		// the tags should not be populated
		$this->verifyNoRevertedTags( $revertedRevs );
	}

	/**
	 * Ensure the patrolling-related job delay mechanism is not used when patrolling
	 * is disabled.
	 */
	public function testNoDelayedJobExecutionWhenPatrollingIsDisabled() {
		$num = 5;

		// disable patrolling
		$this->overrideMwServices( new HashConfig( [ MainConfigNames::UseRCPatrol => false ] ) );

		$page = $this->getExistingTestPage();
		$revisionIds = $this->setupEditsOnPage( $page, $num );

		// Make a manual revert to revision with content '0'
		// The user DOES NOT have the 'autopatrol' right, but that should not matter here
		$revertRevId = $this->editPage(
			$page,
			'0',
			'',
			NS_MAIN,
			$this->getTestUser()->getUser()
		)->value['revision-record']->getId();
		$revertedRevs = array_slice( $revisionIds, 1 );

		DeferredUpdates::doUpdates();

		// the tags should not have been populated yet
		$this->verifyNoRevertedTags( $revertedRevs );

		// run the job
		$this->runJobs( [], [
			'type' => 'revertedTagUpdate'
		] );

		// now the tags should be populated
		$this->verifyRevertedTags( $revertedRevs, $revertRevId );
	}

	/**
	 * In this scenario an extension hook prevents the update from executing.
	 * We also check if the hook is able to override the decision made by the patrol
	 * subsystem.
	 * The update is then re-enqueued when the edit is approved.
	 */
	public function testDelayedJobExecutionWithHook() {
		$num = 5;

		$page = $this->getExistingTestPage();
		$revisionIds = $this->setupEditsOnPage( $page, $num );

		$this->setTemporaryHook(
			'BeforeRevertedTagUpdate',
			function (
				$wikiPage,
				$user,
				$summary,
				$flags,
				$revisionRecord,
				$editResult,
				&$approved
			) {
				$this->assertTrue(
					$approved,
					'$approved parameter of BeforeRevertedTagUpdate'
				);
				$approved = false;
			}
		);

		// Make a manual revert to revision with content '0'
		// The user HAS the 'autopatrol' right, but that should be vetoed by the hook
		$revertRevId = $this->editPage(
			$page,
			'0',
			'',
			NS_MAIN,
			$this->getTestSysop()->getUser()
		)->value['revision-record']->getId();
		$revertedRevs = array_slice( $revisionIds, 1 );

		DeferredUpdates::doUpdates();

		// the tags should not have been populated yet
		$this->verifyNoRevertedTags( $revertedRevs );

		// try to run the job
		$this->runJobs( [ 'numJobs' => 0 ], [
			'type' => 'revertedTagUpdate'
		] );

		// the tags still should not be present as the edit is pending approval
		$this->verifyNoRevertedTags( $revertedRevs );

		// simulate the approval of the edit
		$manager = $this->getServiceContainer()->getRevertedTagUpdateManager();
		$manager->approveRevertedTagForRevision( $revertRevId );

		// run the job
		$this->runJobs( [], [
			'type' => 'revertedTagUpdate'
		] );

		// now the tags should be populated
		$this->verifyRevertedTags( $revertedRevs, $revertRevId );
	}

	/**
	 * Here the patrol subsystem says the edit is not approved, but an extension hook
	 * decides to run the update immediately anyway.
	 */
	public function testNoDelayedJobExecutionWithHook() {
		$num = 5;

		$page = $this->getExistingTestPage();
		$revisionIds = $this->setupEditsOnPage( $page, $num );

		$this->setTemporaryHook(
			'BeforeRevertedTagUpdate',
			function (
				$wikiPage,
				$user,
				$summary,
				$flags,
				$revisionRecord,
				$editResult,
				&$approved
			) {
				$this->assertFalse(
					$approved,
					'$approved parameter of BeforeRevertedTagUpdate'
				);
				$approved = true;
			}
		);

		// Make a manual revert to revision with content '0'
		// The user DOES NOT have the 'autopatrol' right, but that should be
		// overridden by the hook.
		$revertRevId = $this->editPage(
			$page,
			'0',
			'',
			NS_MAIN,
			$this->getTestUser()->getUser()
		)->value['revision-record']->getId();
		$revertedRevs = array_slice( $revisionIds, 1 );

		DeferredUpdates::doUpdates();

		// the tags should not have been populated yet
		$this->verifyNoRevertedTags( $revertedRevs );

		// run the job
		$this->runJobs( [], [
			'type' => 'revertedTagUpdate'
		] );

		// now the tags should be populated
		$this->verifyRevertedTags( $revertedRevs, $revertRevId );
	}

	/**
	 * Sets up a set number of edits on a page.
	 *
	 * @param WikiPage $page the page to set up
	 * @param int $editCount
	 *
	 * @return array
	 */
	private function setupEditsOnPage( WikiPage $page, int $editCount ): array {
		$revIds = [];
		for ( $i = 0; $i <= $editCount; $i++ ) {
			$revIds[] = $this->editPage( $page, strval( $i ) )
				->value['revision-record']->getId();
		}

		return $revIds;
	}

	/**
	 * Ensures that the reverted tag is not set for given revisions.
	 *
	 * @param array $revisionIds
	 */
	private function verifyNoRevertedTags( array $revisionIds ) {
		$dbw = wfGetDB( DB_PRIMARY );
		foreach ( $revisionIds as $revisionId ) {
			$this->assertNotContains(
				'mw-reverted',
				ChangeTags::getTags( $dbw, null, $revisionId ),
				'ChangeTags::getTags()'
			);
		}
	}

	/**
	 * Checks if the provided revisions have their reverted tag set properly.
	 *
	 * @param array $revisionIds
	 * @param int $revertRevId
	 */
	private function verifyRevertedTags(
		array $revisionIds,
		int $revertRevId
	) {
		$dbw = wfGetDB( DB_PRIMARY );
		// for each reverted revision
		foreach ( $revisionIds as $revisionId ) {
			$this->assertContains(
				'mw-reverted',
				ChangeTags::getTags( $dbw, null, $revisionId ),
				'ChangeTags::getTags()'
			);

			// do basic checks for the ct_params field
			$extraParams = $dbw->newSelectQueryBuilder()
				->select( 'ct_params' )
				->from( 'change_tag' )
				->join( 'change_tag_def', null, 'ct_tag_id = ctd_id' )
				->where( [ 'ct_rev_id' => $revisionId, 'ctd_name' => 'mw-reverted' ] )
				->caller( __METHOD__ )->fetchField();
			$this->assertNotEmpty( $extraParams, 'change_tag.ct_params' );
			$this->assertJson( $extraParams, 'change_tag.ct_params' );
			$parsedParams = FormatJson::decode( $extraParams, true );
			$this->assertArraySubmapSame(
				[ 'revertId' => $revertRevId ],
				$parsedParams,
				'change_tag.ct_params'
			);
		}
	}
}
Back to Directory File Manager